From 3b583094fd871d8358e72327a686d67b42f92424 Mon Sep 17 00:00:00 2001 From: Divyanshu Bhargava Date: Fri, 3 Jul 2026 11:25:03 +0530 Subject: [PATCH] feat(bundles): client bundle mode and atomic CLI bundle deploy - StacBundle/StacBundleConfig models; SharedPreferences bundle store with schema-version guard - StacBundleService: conditional sync (ETag/since), seed-asset hydration (highest version wins), updates stream, offline fallback - StacBundleUpdater: sync on app-resume + optional foreground polling - StacCloud serves screens/themes from the bundle when enabled (opt-in; falls through to legacy per-artifact fetch) - CLI: single atomic POST /bundles with checksum + payload size log, seed asset emission with pubspec warning, --legacy fallback flag; build now cleans stale .build outputs --- packages/stac/lib/src/framework/stac.dart | 8 + .../stac/lib/src/framework/stac_service.dart | 17 + packages/stac/lib/src/models/models.dart | 2 + packages/stac/lib/src/models/stac_bundle.dart | 99 +++ .../stac/lib/src/models/stac_bundle.g.dart | 28 + .../lib/src/models/stac_bundle_config.dart | 126 ++++ packages/stac/lib/src/services/services.dart | 2 + .../lib/src/services/stac_bundle_service.dart | 313 ++++++++++ .../lib/src/services/stac_bundle_store.dart | 100 +++ .../lib/src/services/stac_bundle_updater.dart | 83 +++ .../stac/lib/src/services/stac_cloud.dart | 47 ++ .../stac/test/models/stac_bundle_test.dart | 79 +++ .../services/stac_bundle_service_test.dart | 589 ++++++++++++++++++ packages/stac_cli/CHANGELOG.md | 7 + .../lib/src/commands/deploy_command.dart | 9 +- .../lib/src/services/build_service.dart | 7 +- .../lib/src/services/deploy_service.dart | 292 ++++++++- packages/stac_cli/pubspec.lock | 2 +- packages/stac_cli/pubspec.yaml | 1 + .../test/services/deploy_service_test.dart | 300 +++++++++ 20 files changed, 2102 insertions(+), 9 deletions(-) create mode 100644 packages/stac/lib/src/models/stac_bundle.dart create mode 100644 packages/stac/lib/src/models/stac_bundle.g.dart create mode 100644 packages/stac/lib/src/models/stac_bundle_config.dart create mode 100644 packages/stac/lib/src/services/stac_bundle_service.dart create mode 100644 packages/stac/lib/src/services/stac_bundle_store.dart create mode 100644 packages/stac/lib/src/services/stac_bundle_updater.dart create mode 100644 packages/stac/test/models/stac_bundle_test.dart create mode 100644 packages/stac/test/services/stac_bundle_service_test.dart create mode 100644 packages/stac_cli/test/services/deploy_service_test.dart diff --git a/packages/stac/lib/src/framework/stac.dart b/packages/stac/lib/src/framework/stac.dart index a761594b4..e2fd4ebf1 100644 --- a/packages/stac/lib/src/framework/stac.dart +++ b/packages/stac/lib/src/framework/stac.dart @@ -5,6 +5,7 @@ import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:stac/src/framework/stac_error.dart'; import 'package:stac/src/framework/stac_service.dart'; +import 'package:stac/src/models/stac_bundle_config.dart'; import 'package:stac/src/models/stac_cache_config.dart'; import 'package:stac/src/services/stac_cloud.dart'; import 'package:stac_core/actions/network_request/stac_network_request.dart'; @@ -165,6 +166,11 @@ class Stac extends StatelessWidget { /// - [cacheConfig]: Global cache configuration for all Stac widgets and /// StacCloud calls. Defaults to networkFirst strategy if not provided. /// + /// - [bundleConfig]: Configuration for bundle mode, where all screens and + /// themes are downloaded as a single version-gated bundle. Disabled by + /// default (opt-in); when not provided, legacy per-screen fetching is + /// used. + /// /// ## Example /// /// ```dart @@ -188,6 +194,7 @@ class Stac extends StatelessWidget { bool logStackTraces = true, StacErrorWidgetBuilder? errorWidgetBuilder, StacCacheConfig? cacheConfig, + StacBundleConfig? bundleConfig, }) async { return StacService.initialize( options: options, @@ -199,6 +206,7 @@ class Stac extends StatelessWidget { logStackTraces: logStackTraces, errorWidgetBuilder: errorWidgetBuilder, cacheConfig: cacheConfig, + bundleConfig: bundleConfig, ); } diff --git a/packages/stac/lib/src/framework/stac_service.dart b/packages/stac/lib/src/framework/stac_service.dart index b05ff8249..d272a3ee3 100644 --- a/packages/stac/lib/src/framework/stac_service.dart +++ b/packages/stac/lib/src/framework/stac_service.dart @@ -8,6 +8,7 @@ import 'package:flutter/services.dart'; import 'package:stac/src/framework/stac.dart'; import 'package:stac/src/framework/stac_error.dart'; import 'package:stac/src/framework/stac_registry.dart'; +import 'package:stac/src/models/stac_bundle_config.dart'; import 'package:stac/src/models/stac_cache_config.dart'; import 'package:stac/src/parsers/actions/stac_form_validate/stac_form_validate_parser.dart'; import 'package:stac/src/parsers/actions/stac_get_form_value/stac_get_form_value_parser.dart'; @@ -18,6 +19,8 @@ import 'package:stac/src/parsers/widgets/stac_inkwell/stac_inkwell_parser.dart'; import 'package:stac/src/parsers/widgets/stac_row/stac_row_parser.dart'; import 'package:stac/src/parsers/widgets/stac_text/stac_text_parser.dart'; import 'package:stac/src/parsers/widgets/stac_tool_tip/stac_tool_tip_parser.dart'; +import 'package:stac/src/services/stac_bundle_service.dart'; +import 'package:stac/src/services/stac_bundle_updater.dart'; import 'package:stac/src/services/stac_network_service.dart'; import 'package:stac/src/utils/variable_resolver.dart'; import 'package:stac_core/stac_core.dart'; @@ -173,6 +176,10 @@ class StacService { ); static StacCacheConfig get defaultCacheConfig => _defaultCacheConfig; + // Bundle configuration for bundle mode (opt-in, disabled by default). + static StacBundleConfig _bundleConfig = const StacBundleConfig(); + static StacBundleConfig get bundleConfig => _bundleConfig; + static Future initialize({ StacOptions? options, List parsers = const [], @@ -183,11 +190,21 @@ class StacService { bool logStackTraces = true, StacErrorWidgetBuilder? errorWidgetBuilder, StacCacheConfig? cacheConfig, + StacBundleConfig? bundleConfig, }) async { _options = options; if (cacheConfig != null) { _defaultCacheConfig = cacheConfig; } + if (bundleConfig != null) { + _bundleConfig = bundleConfig; + } + if (_bundleConfig.enabled) { + StacBundleUpdater.start(); + if (_bundleConfig.prefetchOnInit && options != null) { + unawaited(StacBundleService.sync()); + } + } _parsers.addAll(parsers); _actionParsers.addAll(actionParsers); StacRegistry.instance.registerAll(_parsers, override); diff --git a/packages/stac/lib/src/models/models.dart b/packages/stac/lib/src/models/models.dart index 34b2f5138..125f3be91 100644 --- a/packages/stac/lib/src/models/models.dart +++ b/packages/stac/lib/src/models/models.dart @@ -1,2 +1,4 @@ +export 'package:stac/src/models/stac_bundle.dart'; +export 'package:stac/src/models/stac_bundle_config.dart'; export 'package:stac/src/models/stac_cache_config.dart'; export 'package:stac/src/models/stac_cache.dart'; diff --git a/packages/stac/lib/src/models/stac_bundle.dart b/packages/stac/lib/src/models/stac_bundle.dart new file mode 100644 index 000000000..ad91cfcab --- /dev/null +++ b/packages/stac/lib/src/models/stac_bundle.dart @@ -0,0 +1,99 @@ +import 'dart:convert'; + +import 'package:json_annotation/json_annotation.dart'; + +part 'stac_bundle.g.dart'; + +/// Model representing a bundle of screens and themes from Stac Cloud. +/// +/// A bundle contains every screen and theme for a project at a single +/// server-owned monotonic [version]. Screens render from the cached bundle; +/// the body is re-downloaded only when a conditional version check returns +/// a new version. +@JsonSerializable() +class StacBundle { + /// Creates a [StacBundle] instance. + const StacBundle({ + required this.projectId, + required this.version, + this.etag, + this.checksum, + required this.fetchedAt, + required this.screens, + required this.themes, + }); + + /// The Stac Cloud project this bundle belongs to. + final String projectId; + + /// The server-owned monotonic bundle version. + final int version; + + /// The HTTP ETag for conditional (`If-None-Match`) requests. + final String? etag; + + /// The sha256 checksum of the bundle content. + final String? checksum; + + /// The timestamp when this bundle was fetched or hydrated. + final DateTime fetchedAt; + + /// Screen name to Stac JSON string. + final Map screens; + + /// Theme name to Stac JSON string. + final Map themes; + + /// Returns the Stac JSON string for the screen [name], or `null` if the + /// screen is not part of this bundle. + String? screen(String name) => screens[name]; + + /// Returns the Stac JSON string for the theme [name], or `null` if the + /// theme is not part of this bundle. + String? theme(String name) => themes[name]; + + /// Creates a [StacBundle] from a JSON map. + factory StacBundle.fromJson(Map json) => + _$StacBundleFromJson(json); + + /// Converts this [StacBundle] to a JSON map. + Map toJson() => _$StacBundleToJson(this); + + /// Creates a [StacBundle] from a JSON string. + factory StacBundle.fromJsonString(String jsonString) { + return StacBundle.fromJson(jsonDecode(jsonString) as Map); + } + + /// Converts this [StacBundle] to a JSON string. + String toJsonString() { + return jsonEncode(toJson()); + } + + /// Creates a copy of this [StacBundle] with the given fields replaced. + StacBundle copyWith({ + String? projectId, + int? version, + String? etag, + String? checksum, + DateTime? fetchedAt, + Map? screens, + Map? themes, + }) { + return StacBundle( + projectId: projectId ?? this.projectId, + version: version ?? this.version, + etag: etag ?? this.etag, + checksum: checksum ?? this.checksum, + fetchedAt: fetchedAt ?? this.fetchedAt, + screens: screens ?? this.screens, + themes: themes ?? this.themes, + ); + } + + @override + String toString() { + return 'StacBundle(projectId: $projectId, version: $version, ' + 'screens: ${screens.length}, themes: ${themes.length}, ' + 'fetchedAt: $fetchedAt)'; + } +} diff --git a/packages/stac/lib/src/models/stac_bundle.g.dart b/packages/stac/lib/src/models/stac_bundle.g.dart new file mode 100644 index 000000000..8039702cf --- /dev/null +++ b/packages/stac/lib/src/models/stac_bundle.g.dart @@ -0,0 +1,28 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'stac_bundle.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +StacBundle _$StacBundleFromJson(Map json) => StacBundle( + projectId: json['projectId'] as String, + version: (json['version'] as num).toInt(), + etag: json['etag'] as String?, + checksum: json['checksum'] as String?, + fetchedAt: DateTime.parse(json['fetchedAt'] as String), + screens: Map.from(json['screens'] as Map), + themes: Map.from(json['themes'] as Map), +); + +Map _$StacBundleToJson(StacBundle instance) => + { + 'projectId': instance.projectId, + 'version': instance.version, + 'etag': instance.etag, + 'checksum': instance.checksum, + 'fetchedAt': instance.fetchedAt.toIso8601String(), + 'screens': instance.screens, + 'themes': instance.themes, + }; diff --git a/packages/stac/lib/src/models/stac_bundle_config.dart b/packages/stac/lib/src/models/stac_bundle_config.dart new file mode 100644 index 000000000..18f82c5b1 --- /dev/null +++ b/packages/stac/lib/src/models/stac_bundle_config.dart @@ -0,0 +1,126 @@ +/// Configuration for Stac bundle mode. +/// +/// Bundle mode replaces per-screen fetches with a single version-gated +/// bundle download containing every screen and theme for a project. +/// It is opt-in: [enabled] defaults to `false`, so an app without a +/// `bundleConfig` behaves exactly as before. +/// +/// Bundle caching behavior is fixed (no strategies): screens always render +/// from the cached bundle, and the body is re-downloaded only when a +/// conditional version check returns a new version. The check runs at app +/// launch ([prefetchOnInit]), on app-resume ([checkOnResume]), on an optional +/// poll interval ([pollingInterval]), or via an explicit +/// `StacBundleService.sync()` call. +/// +/// ## Basic Usage +/// +/// ```dart +/// await Stac.initialize( +/// options: StacOptions(...), +/// bundleConfig: StacBundleConfig( +/// enabled: true, +/// seedAsset: 'assets/stac_bundle.json', +/// ), +/// ); +/// ``` +class StacBundleConfig { + /// Creates a [StacBundleConfig] instance. + const StacBundleConfig({ + this.enabled = false, + this.prefetchOnInit = true, + this.baseUrl = 'https://api.stac.dev', + this.seedAsset, + this.checkOnResume = true, + this.pollingInterval, + }); + + /// Whether bundle mode is enabled. + /// + /// Defaults to `false` (opt-in). When disabled, the legacy per-artifact + /// fetch and cache behavior is untouched. + final bool enabled; + + /// Whether to kick off a conditional bundle sync during initialization. + /// + /// Defaults to `true`. The sync is not awaited, so it never delays startup. + final bool prefetchOnInit; + + /// The base URL used for bundle requests. + /// + /// Defaults to `https://api.stac.dev`. + final String baseUrl; + + /// Optional asset path of a seed bundle shipped with the app + /// (e.g. `assets/stac_bundle.json`, written by `stac deploy`). + /// + /// When set, first launch hydrates from the seed instantly — no loading + /// wait, and it works offline. Defaults to `null` (no seed). + final String? seedAsset; + + /// Whether to run a conditional bundle sync when the app returns to the + /// foreground. + /// + /// Defaults to `true`. + final bool checkOnResume; + + /// Optional interval for periodic conditional bundle syncs while the app + /// is foregrounded. + /// + /// Defaults to `null` (polling off). + final Duration? pollingInterval; + + // ───────────────────────────────────────────────────────────────────────── + // Methods + // ───────────────────────────────────────────────────────────────────────── + + /// Creates a copy of this config with the given fields replaced. + StacBundleConfig copyWith({ + bool? enabled, + bool? prefetchOnInit, + String? baseUrl, + String? seedAsset, + bool? checkOnResume, + Duration? pollingInterval, + }) { + return StacBundleConfig( + enabled: enabled ?? this.enabled, + prefetchOnInit: prefetchOnInit ?? this.prefetchOnInit, + baseUrl: baseUrl ?? this.baseUrl, + seedAsset: seedAsset ?? this.seedAsset, + checkOnResume: checkOnResume ?? this.checkOnResume, + pollingInterval: pollingInterval ?? this.pollingInterval, + ); + } + + @override + String toString() { + return 'StacBundleConfig(enabled: $enabled, prefetchOnInit: $prefetchOnInit, ' + 'baseUrl: $baseUrl, seedAsset: $seedAsset, checkOnResume: $checkOnResume, ' + 'pollingInterval: $pollingInterval)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is StacBundleConfig && + other.enabled == enabled && + other.prefetchOnInit == prefetchOnInit && + other.baseUrl == baseUrl && + other.seedAsset == seedAsset && + other.checkOnResume == checkOnResume && + other.pollingInterval == pollingInterval; + } + + @override + int get hashCode { + return Object.hash( + enabled, + prefetchOnInit, + baseUrl, + seedAsset, + checkOnResume, + pollingInterval, + ); + } +} diff --git a/packages/stac/lib/src/services/services.dart b/packages/stac/lib/src/services/services.dart index c9fdb6246..c07ed940e 100644 --- a/packages/stac/lib/src/services/services.dart +++ b/packages/stac/lib/src/services/services.dart @@ -1,2 +1,4 @@ +export 'package:stac/src/services/stac_bundle_service.dart'; +export 'package:stac/src/services/stac_bundle_store.dart'; export 'package:stac/src/services/stac_cache_service.dart'; export 'package:stac/src/services/stac_network_service.dart'; diff --git a/packages/stac/lib/src/services/stac_bundle_service.dart b/packages/stac/lib/src/services/stac_bundle_service.dart new file mode 100644 index 000000000..33937716d --- /dev/null +++ b/packages/stac/lib/src/services/stac_bundle_service.dart @@ -0,0 +1,313 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:stac/src/framework/stac_service.dart'; +import 'package:stac/src/models/stac_bundle.dart'; +import 'package:stac/src/models/stac_bundle_config.dart'; +import 'package:stac/src/services/stac_bundle_store.dart'; +import 'package:stac_logger/stac_logger.dart'; + +/// Reads a string asset; seam for testing seed hydration without a real +/// asset bundle. +typedef StacBundleAssetReader = Future Function(String assetPath); + +/// Service for syncing and serving Stac bundles. +/// +/// A bundle is a single blob containing every screen and theme for a +/// project at one server-owned version. Screens always render from the +/// cached bundle; the body is re-downloaded only when a conditional +/// version check ([sync]) returns a new version (decision: no +/// strategies/TTL — the server-owned version is the only invalidator). +class StacBundleService { + const StacBundleService._(); + + static Dio _dio = _createDio(); + + static Dio _createDio() { + return Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 30), + ), + ); + } + + /// Overrides the Dio client used for bundle requests. + @visibleForTesting + static set dio(Dio dio) => _dio = dio; + + static StacBundleStore _store = const SharedPreferencesBundleStore(); + + /// Overrides the store used to persist bundles. + @visibleForTesting + static set store(StacBundleStore store) => _store = store; + + static StacBundleAssetReader _assetReader = rootBundle.loadString; + + /// Overrides the asset reader used for seed bundle hydration. + @visibleForTesting + static set assetReader(StacBundleAssetReader reader) => + _assetReader = reader; + + /// The bundle currently held in memory, if any. + static StacBundle? _bundle; + + /// The bundle currently held in memory, if any. + static StacBundle? get current => _bundle; + + /// Whether hydration (store + seed asset) has completed. + static bool _hydrated = false; + + /// In-flight hydration, deduped across concurrent callers. + static Future? _inFlightHydration; + + /// In-flight sync, deduped so at most one GET runs at a time. + static Future? _inFlightSync; + + static StreamController _updatesController = + StreamController.broadcast(); + + /// Broadcast stream emitting whenever a sync lands a new bundle version, + /// so host apps can prompt the user or trigger a rebuild on their own + /// terms. Nothing is emitted on `304`/`204` (no change) or on errors. + static Stream get updates => _updatesController.stream; + + static StacBundleConfig get _config => StacService.bundleConfig; + + static String get _projectId { + final options = StacService.options; + if (options == null) { + throw Exception('StacOptions is not set'); + } + return options.projectId; + } + + /// Runs a conditional bundle version check against the server. + /// + /// Sends `GET {baseUrl}/bundles?projectId=..&since=` with + /// `If-None-Match` when an etag is cached. A `200` parses, persists and + /// swaps in the new bundle (and emits on [updates]); `304`/`204` keep the + /// current bundle; errors (offline, server failures) return the stale + /// bundle. Concurrent calls are deduped onto a single in-flight request. + /// + /// Set [force] to skip the conditional headers and re-download the body + /// unconditionally. + static Future sync({bool force = false}) { + return _inFlightSync ??= _syncInternal(force: force).whenComplete(() { + _inFlightSync = null; + }); + } + + static Future _syncInternal({required bool force}) async { + final projectId = _projectId; + + // Hydrate first so the request can be conditional on the cached version. + await _hydrate(); + + final cached = _bundle; + try { + final response = await _dio.get( + '${_config.baseUrl}/bundles', + queryParameters: { + 'projectId': projectId, + if (!force && cached != null) 'since': cached.version, + }, + options: Options( + headers: { + if (!force && cached?.etag != null) 'If-None-Match': cached!.etag, + }, + validateStatus: (status) => status != null && status < 500, + ), + ); + + final statusCode = response.statusCode; + + if (statusCode == 200) { + final data = response.data; + if (data is! Map) { + Log.w('StacBundleService: Unexpected bundle response body'); + return cached; + } + + final bundle = _bundleFromResponse( + Map.from(data), + projectId: projectId, + etagHeader: response.headers.value('etag'), + ); + if (bundle == null) { + Log.w('StacBundleService: Bundle response is missing a version'); + return cached; + } + + _bundle = bundle; + await _store.write(bundle); + _updatesController.add(bundle); + Log.d('StacBundleService: Synced bundle v${bundle.version}'); + return bundle; + } + + if (statusCode == 304 || statusCode == 204) { + // Not modified: keep the cached bundle. + return cached; + } + + Log.w( + 'StacBundleService: Bundle sync failed with status $statusCode, ' + 'using ${cached == null ? 'no bundle' : 'stale bundle v${cached.version}'}', + ); + return cached; + } catch (e) { + // Offline or server error: keep serving the stale bundle. + Log.d('StacBundleService: Bundle sync failed ($e), using stale bundle'); + return cached; + } + } + + /// Builds a [StacBundle] from a `200` response body. + /// + /// Returns `null` when the body has no usable version. + static StacBundle? _bundleFromResponse( + Map data, { + required String projectId, + String? etagHeader, + }) { + final version = data['version']; + if (version is! int) return null; + + return StacBundle( + projectId: data['projectId'] as String? ?? projectId, + version: version, + etag: etagHeader ?? data['etag'] as String?, + checksum: data['checksum'] as String?, + fetchedAt: DateTime.now(), + screens: _stringMap(data['screens']), + themes: _stringMap(data['themes']), + ); + } + + static Map _stringMap(dynamic value) { + if (value is! Map) return const {}; + return Map.from(value); + } + + /// Ensures a bundle is loaded, hydrating from on-device storage and the + /// seed asset (highest version wins) without any network call. + /// + /// Only when no bundle exists in either source (first launch of a + /// seedless app) does this await [sync] so the first screens can render. + static Future ensureLoaded() async { + await _hydrate(); + if (_bundle != null) return _bundle; + return sync(); + } + + /// Hydrates the in-memory bundle once from the store and seed asset. + static Future _hydrate() { + if (_hydrated) return Future.value(); + return _inFlightHydration ??= _hydrateInternal().whenComplete(() { + _hydrated = true; + _inFlightHydration = null; + }); + } + + static Future _hydrateInternal() async { + final projectId = _projectId; + + // Read the persisted bundle (schema mismatch reads as empty). + var stored = await _store.read(projectId); + if (stored != null && stored.projectId != projectId) { + // Stale data from another project: clear it. + Log.w( + 'StacBundleService: Stored bundle belongs to project ' + '${stored.projectId}, expected $projectId — clearing', + ); + await _store.clear(projectId); + stored = null; + } + + // Read the seed asset shipped with the app, when configured. + final seed = await _readSeed(projectId); + + // Highest version wins (an app-store update can ship a seed newer than + // the cached bundle, and vice versa). + if (seed != null && (stored == null || seed.version > stored.version)) { + _bundle = seed; + await _store.write(seed); + Log.d('StacBundleService: Hydrated from seed bundle v${seed.version}'); + } else if (stored != null) { + _bundle = stored; + Log.d('StacBundleService: Hydrated from stored bundle v${stored.version}'); + } + } + + /// Loads and parses the seed bundle asset, tolerating a missing or + /// malformed asset. + static Future _readSeed(String projectId) async { + final seedAsset = _config.seedAsset; + if (seedAsset == null) return null; + + try { + final raw = await _assetReader(seedAsset); + final data = jsonDecode(raw); + if (data is! Map) { + Log.w('StacBundleService: Seed asset $seedAsset is not a JSON object'); + return null; + } + + final map = Map.from(data); + // Seed bundles written by `stac deploy` carry no fetchedAt. + map.putIfAbsent('fetchedAt', () => DateTime.now().toIso8601String()); + + final seed = StacBundle.fromJson(map); + if (seed.projectId != projectId) { + Log.w( + 'StacBundleService: Seed bundle belongs to project ' + '${seed.projectId}, expected $projectId — ignoring', + ); + return null; + } + return seed; + } catch (e) { + // Missing or unreadable seed asset is not an error. + Log.d('StacBundleService: No usable seed bundle at $seedAsset ($e)'); + return null; + } + } + + /// Returns the Stac JSON string for the screen [name] from the bundle, + /// or `null` if the screen is not in the bundle. + static Future getScreenJson(String name) async { + final bundle = await ensureLoaded(); + return bundle?.screen(name); + } + + /// Returns the Stac JSON string for the theme [name] from the bundle, + /// or `null` if the theme is not in the bundle. + static Future getThemeJson(String name) async { + final bundle = await ensureLoaded(); + return bundle?.theme(name); + } + + /// Clears the persisted and in-memory bundle for the current project. + static Future clear() async { + _bundle = null; + return _store.clear(_projectId); + } + + /// Resets all static state; for tests only. + @visibleForTesting + static void reset() { + _bundle = null; + _hydrated = false; + _inFlightHydration = null; + _inFlightSync = null; + _dio = _createDio(); + _store = const SharedPreferencesBundleStore(); + _assetReader = rootBundle.loadString; + _updatesController.close(); + _updatesController = StreamController.broadcast(); + } +} diff --git a/packages/stac/lib/src/services/stac_bundle_store.dart b/packages/stac/lib/src/services/stac_bundle_store.dart new file mode 100644 index 000000000..3bfaafeb5 --- /dev/null +++ b/packages/stac/lib/src/services/stac_bundle_store.dart @@ -0,0 +1,100 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:stac/src/models/stac_bundle.dart'; +import 'package:stac_logger/stac_logger.dart'; + +/// Storage abstraction for persisting Stac bundles on-device. +/// +/// The default implementation is [SharedPreferencesBundleStore]; hosts and +/// tests can provide their own implementation via +/// `StacBundleService.store`. +abstract class StacBundleStore { + /// Reads the persisted bundle for [projectId]. + /// + /// Returns `null` when no bundle is stored, when the stored blob cannot + /// be parsed, or when the stored schema version does not match. + Future read(String projectId); + + /// Persists [bundle] for its project. + Future write(StacBundle bundle); + + /// Removes the persisted bundle for [projectId]. + Future clear(String projectId); +} + +/// [StacBundleStore] backed by SharedPreferences. +/// +/// Stores the bundle blob at key `stac_bundle_{projectId}` with an int +/// schema version alongside it; a schema mismatch on read is treated as +/// an empty store. +class SharedPreferencesBundleStore implements StacBundleStore { + /// Creates a [SharedPreferencesBundleStore] instance. + const SharedPreferencesBundleStore(); + + /// Schema version of the persisted bundle blob. + /// + /// Bump this when the [StacBundle] persistence format changes in a + /// backward-incompatible way; older blobs are then treated as empty. + static const int bundleSchemaVersion = 1; + + static String _bundleKey(String projectId) => 'stac_bundle_$projectId'; + + static String _schemaKey(String projectId) => + 'stac_bundle_schema_$projectId'; + + @override + Future read(String projectId) async { + try { + final prefs = await SharedPreferences.getInstance(); + + final schemaVersion = prefs.getInt(_schemaKey(projectId)); + if (schemaVersion != bundleSchemaVersion) { + // Missing or mismatched schema version: treat as empty. + return null; + } + + final blob = prefs.getString(_bundleKey(projectId)); + if (blob == null) { + return null; + } + + return StacBundle.fromJsonString(blob); + } catch (e) { + Log.w('StacBundleStore: Failed to read bundle for $projectId: $e'); + return null; + } + } + + @override + Future write(StacBundle bundle) async { + try { + final prefs = await SharedPreferences.getInstance(); + final wroteBlob = await prefs.setString( + _bundleKey(bundle.projectId), + bundle.toJsonString(), + ); + final wroteSchema = await prefs.setInt( + _schemaKey(bundle.projectId), + bundleSchemaVersion, + ); + return wroteBlob && wroteSchema; + } catch (e) { + Log.w( + 'StacBundleStore: Failed to write bundle for ${bundle.projectId}: $e', + ); + return false; + } + } + + @override + Future clear(String projectId) async { + try { + final prefs = await SharedPreferences.getInstance(); + final removedBlob = await prefs.remove(_bundleKey(projectId)); + final removedSchema = await prefs.remove(_schemaKey(projectId)); + return removedBlob && removedSchema; + } catch (e) { + Log.w('StacBundleStore: Failed to clear bundle for $projectId: $e'); + return false; + } + } +} diff --git a/packages/stac/lib/src/services/stac_bundle_updater.dart b/packages/stac/lib/src/services/stac_bundle_updater.dart new file mode 100644 index 000000000..08694d7f7 --- /dev/null +++ b/packages/stac/lib/src/services/stac_bundle_updater.dart @@ -0,0 +1,83 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:stac/src/framework/stac_service.dart'; +import 'package:stac/src/services/stac_bundle_service.dart'; + +/// Keeps the Stac bundle fresh while the app runs. +/// +/// Registered by `StacService.initialize` when bundle mode is enabled. +/// Runs a conditional bundle sync on foreground resume (when +/// `StacBundleConfig.checkOnResume`) and on an optional periodic timer +/// (`StacBundleConfig.pollingInterval`) that only runs while the app is +/// foregrounded (cancelled on pause, restarted on resume). +/// +/// Every trigger is just the cheap conditional GET — a `304` is a no-op +/// and is not billed. +class StacBundleUpdater with WidgetsBindingObserver { + StacBundleUpdater._(); + + static StacBundleUpdater? _instance; + + /// The registered updater instance, if started; for tests only. + @visibleForTesting + static StacBundleUpdater? get instance => _instance; + + Timer? _pollingTimer; + + /// Registers the updater as a lifecycle observer and starts the polling + /// timer when configured. Does nothing if already started. + static void start() { + if (_instance != null) return; + + final updater = StacBundleUpdater._(); + WidgetsBinding.instance.addObserver(updater); + // The app starts foregrounded; arm the polling timer right away. + updater._startPollingTimer(); + _instance = updater; + } + + /// Unregisters the updater and cancels any polling timer. + static void stop() { + final updater = _instance; + if (updater == null) return; + + updater._cancelPollingTimer(); + WidgetsBinding.instance.removeObserver(updater); + _instance = null; + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.resumed: + if (StacService.bundleConfig.checkOnResume) { + unawaited(StacBundleService.sync()); + } + _startPollingTimer(); + case AppLifecycleState.paused: + case AppLifecycleState.hidden: + case AppLifecycleState.detached: + _cancelPollingTimer(); + case AppLifecycleState.inactive: + // Transient state (e.g. system dialogs): keep the timer running. + break; + } + } + + /// Starts the periodic conditional sync when [StacBundleConfig.pollingInterval] + /// is set and no timer is already running. + void _startPollingTimer() { + final interval = StacService.bundleConfig.pollingInterval; + if (interval == null) return; + + _pollingTimer ??= Timer.periodic(interval, (_) { + unawaited(StacBundleService.sync()); + }); + } + + void _cancelPollingTimer() { + _pollingTimer?.cancel(); + _pollingTimer = null; + } +} diff --git a/packages/stac/lib/src/services/stac_cloud.dart b/packages/stac/lib/src/services/stac_cloud.dart index 283516a2a..6184d8728 100644 --- a/packages/stac/lib/src/services/stac_cloud.dart +++ b/packages/stac/lib/src/services/stac_cloud.dart @@ -3,6 +3,7 @@ import 'package:stac/src/framework/stac_service.dart'; import 'package:stac/src/models/stac_artifact_type.dart'; import 'package:stac/src/models/stac_cache_config.dart'; import 'package:stac/src/models/stac_cache.dart'; +import 'package:stac/src/services/stac_bundle_service.dart'; import 'package:stac/src/services/stac_cache_service.dart'; import 'package:stac_logger/stac_logger.dart'; @@ -61,6 +62,24 @@ class StacCloud { throw Exception('StacOptions is not set'); } + // Bundle mode: serve the artifact from the cached bundle (decision 10 — + // renders never hit the network; sync keeps the bundle fresh). + if (StacService.bundleConfig.enabled) { + final bundleResponse = await _fetchArtifactFromBundle( + artifactType: artifactType, + artifactName: artifactName, + ); + if (bundleResponse != null) { + return bundleResponse; + } + // Absent from the bundle (deleted vs. not-yet-bundled is + // indistinguishable): fall through to the legacy per-artifact fetch. + Log.w( + 'StacCloud: ${artifactType.name} $artifactName not found in bundle, ' + 'falling back to per-artifact fetch', + ); + } + final cacheConfig = StacService.defaultCacheConfig; // Handle network-only strategy @@ -284,6 +303,34 @@ class StacCloud { return response; } + /// Fetches an artifact from the cached bundle (bundle mode). + /// + /// Returns a [Response] shaped exactly like [_buildArtifactCacheResponse], + /// or `null` when the artifact is absent from the bundle. + static Future _fetchArtifactFromBundle({ + required StacArtifactType artifactType, + required String artifactName, + }) async { + final stacJson = switch (artifactType) { + StacArtifactType.screen => await StacBundleService.getScreenJson( + artifactName, + ), + StacArtifactType.theme => await StacBundleService.getThemeJson( + artifactName, + ), + }; + if (stacJson == null) return null; + + return Response( + requestOptions: RequestOptions(path: _getFetchUrl(artifactType)), + data: { + 'name': artifactName, + 'stacJson': stacJson, + 'version': StacBundleService.current?.version ?? 0, + }, + ); + } + /// Builds a Response from cached artifact data. static Response _buildArtifactCacheResponse( StacArtifactType artifactType, diff --git a/packages/stac/test/models/stac_bundle_test.dart b/packages/stac/test/models/stac_bundle_test.dart new file mode 100644 index 000000000..d12a2d235 --- /dev/null +++ b/packages/stac/test/models/stac_bundle_test.dart @@ -0,0 +1,79 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stac/src/models/stac_bundle.dart'; + +void main() { + final bundle = StacBundle( + projectId: 'proj_1', + version: 3, + etag: '"3"', + checksum: 'abc123', + fetchedAt: DateTime.utc(2026, 7, 3, 12), + screens: const { + 'home': '{"type":"text","data":"Home"}', + 'profile': '{"type":"text","data":"Profile"}', + }, + themes: const {'light': '{"brightness":"light"}'}, + ); + + group('StacBundle', () { + test('round-trips through toJson/fromJson', () { + final restored = StacBundle.fromJson(bundle.toJson()); + + expect(restored.projectId, bundle.projectId); + expect(restored.version, bundle.version); + expect(restored.etag, bundle.etag); + expect(restored.checksum, bundle.checksum); + expect(restored.fetchedAt, bundle.fetchedAt); + expect(restored.screens, bundle.screens); + expect(restored.themes, bundle.themes); + }); + + test('round-trips through toJsonString/fromJsonString', () { + final restored = StacBundle.fromJsonString(bundle.toJsonString()); + + expect(restored.projectId, bundle.projectId); + expect(restored.version, bundle.version); + expect(restored.etag, bundle.etag); + expect(restored.checksum, bundle.checksum); + expect(restored.fetchedAt, bundle.fetchedAt); + expect(restored.screens, bundle.screens); + expect(restored.themes, bundle.themes); + }); + + test('round-trips null etag and checksum', () { + final minimal = StacBundle( + projectId: 'proj_1', + version: 1, + fetchedAt: DateTime.utc(2026), + screens: const {}, + themes: const {}, + ); + + final restored = StacBundle.fromJsonString(minimal.toJsonString()); + + expect(restored.etag, isNull); + expect(restored.checksum, isNull); + expect(restored.screens, isEmpty); + expect(restored.themes, isEmpty); + }); + + test('screen and theme lookups return json or null', () { + expect(bundle.screen('home'), '{"type":"text","data":"Home"}'); + expect(bundle.theme('light'), '{"brightness":"light"}'); + expect(bundle.screen('missing'), isNull); + expect(bundle.theme('missing'), isNull); + }); + + test('copyWith replaces given fields and keeps the rest', () { + final copy = bundle.copyWith(version: 4, etag: '"4"'); + + expect(copy.version, 4); + expect(copy.etag, '"4"'); + expect(copy.projectId, bundle.projectId); + expect(copy.checksum, bundle.checksum); + expect(copy.fetchedAt, bundle.fetchedAt); + expect(copy.screens, bundle.screens); + expect(copy.themes, bundle.themes); + }); + }); +} diff --git a/packages/stac/test/services/stac_bundle_service_test.dart b/packages/stac/test/services/stac_bundle_service_test.dart new file mode 100644 index 000000000..abf20f42d --- /dev/null +++ b/packages/stac/test/services/stac_bundle_service_test.dart @@ -0,0 +1,589 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:stac/src/framework/stac_service.dart'; +import 'package:stac/src/models/stac_bundle.dart'; +import 'package:stac/src/models/stac_bundle_config.dart'; +import 'package:stac/src/services/stac_bundle_service.dart'; +import 'package:stac/src/services/stac_bundle_store.dart'; +import 'package:stac/src/services/stac_bundle_updater.dart'; +import 'package:stac_core/stac_core.dart'; + +/// Fake [HttpClientAdapter] that records requests and answers via [handler]. +class _FakeHttpClientAdapter implements HttpClientAdapter { + ResponseBody Function(RequestOptions options) handler = (options) { + throw StateError('Unexpected network request to ${options.uri}'); + }; + + final List requests = []; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + requests.add(options); + return handler(options); + } + + @override + void close({bool force = false}) {} +} + +/// [StacBundleStore] spy that counts operations and delegates to [inner]. +class _SpyBundleStore implements StacBundleStore { + final StacBundleStore inner = const SharedPreferencesBundleStore(); + int readCount = 0; + int writeCount = 0; + int clearCount = 0; + + @override + Future read(String projectId) { + readCount++; + return inner.read(projectId); + } + + @override + Future write(StacBundle bundle) { + writeCount++; + return inner.write(bundle); + } + + @override + Future clear(String projectId) { + clearCount++; + return inner.clear(projectId); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const projectId = 'proj_1'; + + late _FakeHttpClientAdapter adapter; + + Map bundleBody({ + String projectId = projectId, + int version = 1, + Map? screens, + Map? themes, + }) { + return { + 'projectId': projectId, + 'version': version, + 'etag': '"$version"', + 'checksum': 'checksum-v$version', + 'screens': screens ?? {'home': '{"type":"text","data":"v$version"}'}, + 'themes': themes ?? {'light': '{"brightness":"light-v$version"}'}, + }; + } + + ResponseBody jsonResponse( + Map body, { + int statusCode = 200, + Map>? headers, + }) { + return ResponseBody.fromString( + jsonEncode(body), + statusCode, + headers: { + Headers.contentTypeHeader: ['application/json'], + ...?headers, + }, + ); + } + + ResponseBody statusResponse(int statusCode) { + return ResponseBody.fromString('', statusCode); + } + + StacBundle storedBundle({ + String projectId = projectId, + int version = 1, + Map? screens, + Map? themes, + }) { + return StacBundle( + projectId: projectId, + version: version, + etag: '"$version"', + checksum: 'checksum-v$version', + fetchedAt: DateTime(2026, 7, 1), + screens: screens ?? {'home': '{"type":"text","data":"v$version"}'}, + themes: themes ?? {'light': '{"brightness":"light-v$version"}'}, + ); + } + + Future initStac({ + String projectId = projectId, + StacBundleConfig bundleConfig = const StacBundleConfig( + enabled: true, + prefetchOnInit: false, + ), + }) { + return StacService.initialize( + options: StacOptions(name: 'Test', projectId: projectId), + bundleConfig: bundleConfig, + ); + } + + String? headerOf(RequestOptions options, String name) { + for (final entry in options.headers.entries) { + if (entry.key.toLowerCase() == name.toLowerCase()) { + return entry.value?.toString(); + } + } + return null; + } + + Future pumpUntil( + bool Function() condition, { + Duration timeout = const Duration(seconds: 5), + }) async { + final deadline = DateTime.now().add(timeout); + while (!condition() && DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 5)); + } + } + + setUp(() { + SharedPreferences.setMockInitialValues({}); + StacBundleUpdater.stop(); + StacBundleService.reset(); + + adapter = _FakeHttpClientAdapter(); + StacBundleService.dio = Dio()..httpClientAdapter = adapter; + }); + + tearDown(() { + StacBundleUpdater.stop(); + StacBundleService.reset(); + }); + + group('sync', () { + test('200 parses, persists and swaps the bundle', () async { + adapter.handler = (options) => jsonResponse( + bundleBody(version: 1), + headers: { + 'etag': ['"1"'], + }, + ); + await initStac(); + + final result = await StacBundleService.sync(); + + expect(result, isNotNull); + expect(result!.version, 1); + expect(result.projectId, projectId); + expect(result.etag, '"1"'); + expect(StacBundleService.current?.version, 1); + + // Persisted blob + schema version. + final prefs = await SharedPreferences.getInstance(); + final blob = prefs.getString('stac_bundle_$projectId'); + expect(blob, isNotNull); + expect(StacBundle.fromJsonString(blob!).version, 1); + expect( + prefs.getInt('stac_bundle_schema_$projectId'), + SharedPreferencesBundleStore.bundleSchemaVersion, + ); + + // The first request is unconditional (nothing cached). + final request = adapter.requests.single; + expect(request.uri.queryParameters['projectId'], projectId); + expect(request.uri.queryParameters.containsKey('since'), isFalse); + expect(headerOf(request, 'If-None-Match'), isNull); + + // Lookups now serve from the synced bundle with no further requests. + expect( + await StacBundleService.getScreenJson('home'), + '{"type":"text","data":"v1"}', + ); + expect(adapter.requests, hasLength(1)); + }); + + test( + 'sends If-None-Match and since; 304 keeps cache without rewrite', + () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + final spy = _SpyBundleStore(); + StacBundleService.store = spy; + adapter.handler = (options) => statusResponse(304); + await initStac(); + + final result = await StacBundleService.sync(); + + final request = adapter.requests.single; + expect(request.uri.queryParameters['since'], '1'); + expect(headerOf(request, 'If-None-Match'), '"1"'); + + expect(result?.version, 1); + expect(StacBundleService.current?.version, 1); + expect(spy.writeCount, 0); + }, + ); + + test('204 keeps cache without rewrite', () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + final spy = _SpyBundleStore(); + StacBundleService.store = spy; + adapter.handler = (options) => statusResponse(204); + await initStac(); + + final result = await StacBundleService.sync(); + + expect(result?.version, 1); + expect(spy.writeCount, 0); + }); + + test('version bump swaps memory and store', () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + adapter.handler = (options) => jsonResponse(bundleBody(version: 2)); + await initStac(); + + expect(await StacBundleService.getScreenJson('home'), contains('v1')); + + final result = await StacBundleService.sync(); + + expect(result?.version, 2); + expect(StacBundleService.current?.version, 2); + expect( + await StacBundleService.getScreenJson('home'), + '{"type":"text","data":"v2"}', + ); + + final persisted = await const SharedPreferencesBundleStore().read( + projectId, + ); + expect(persisted?.version, 2); + }); + + test('offline sync returns the stale bundle', () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + adapter.handler = (options) => throw Exception('offline'); + await initStac(); + + final result = await StacBundleService.sync(); + + expect(result?.version, 1); + expect(StacBundleService.current?.version, 1); + expect(await StacBundleService.getScreenJson('home'), contains('v1')); + }); + + test('server error status returns the stale bundle', () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + adapter.handler = (options) => + jsonResponse({'error': 'limit'}, statusCode: 403); + await initStac(); + + final result = await StacBundleService.sync(); + + expect(result?.version, 1); + expect(StacBundleService.current?.version, 1); + }); + + test('updates emits on 200 and not on 304', () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + await initStac(); + + final events = []; + final subscription = StacBundleService.updates.listen(events.add); + addTearDown(subscription.cancel); + + adapter.handler = (options) => jsonResponse(bundleBody(version: 2)); + await StacBundleService.sync(); + await Future.delayed(Duration.zero); + + expect(events, hasLength(1)); + expect(events.single.version, 2); + + adapter.handler = (options) => statusResponse(304); + await StacBundleService.sync(); + await Future.delayed(Duration.zero); + + expect(events, hasLength(1)); + }); + }); + + group('ensureLoaded', () { + test('concurrent calls dedupe to a single network request', () async { + adapter.handler = (options) => jsonResponse(bundleBody(version: 1)); + await initStac(); + + final results = await Future.wait([ + StacBundleService.ensureLoaded(), + StacBundleService.ensureLoaded(), + StacBundleService.ensureLoaded(), + ]); + + expect(adapter.requests, hasLength(1)); + for (final result in results) { + expect(result?.version, 1); + } + }); + + test('hydrated cache lookups make zero network calls', () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + await initStac(); + + final result = await StacBundleService.ensureLoaded(); + + expect(result?.version, 1); + expect( + await StacBundleService.getScreenJson('home'), + '{"type":"text","data":"v1"}', + ); + expect( + await StacBundleService.getThemeJson('light'), + '{"brightness":"light-v1"}', + ); + expect(adapter.requests, isEmpty); + }); + + test('screen absent from the bundle returns null', () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + await initStac(); + + expect(await StacBundleService.getScreenJson('deleted'), isNull); + expect(await StacBundleService.getThemeJson('deleted'), isNull); + expect(adapter.requests, isEmpty); + }); + + test('stored bundle for another project is cleared', () async { + // A blob under this project's key that claims another projectId. + SharedPreferences.setMockInitialValues({ + 'stac_bundle_proj_2': storedBundle( + projectId: projectId, + version: 5, + ).toJsonString(), + 'stac_bundle_schema_proj_2': + SharedPreferencesBundleStore.bundleSchemaVersion, + }); + adapter.handler = (options) => + jsonResponse(bundleBody(projectId: 'proj_2', version: 1)); + await initStac(projectId: 'proj_2'); + + final result = await StacBundleService.ensureLoaded(); + + // The mismatched bundle was discarded, so the request is unconditional. + final request = adapter.requests.single; + expect(request.uri.queryParameters.containsKey('since'), isFalse); + + expect(result?.projectId, 'proj_2'); + expect(result?.version, 1); + + final persisted = await const SharedPreferencesBundleStore().read( + 'proj_2', + ); + expect(persisted?.projectId, 'proj_2'); + expect(persisted?.version, 1); + }); + + test('schema version mismatch reads as empty', () async { + SharedPreferences.setMockInitialValues({ + 'stac_bundle_$projectId': storedBundle(version: 5).toJsonString(), + 'stac_bundle_schema_$projectId': 999, + }); + adapter.handler = (options) => jsonResponse(bundleBody(version: 1)); + await initStac(); + + final result = await StacBundleService.ensureLoaded(); + + expect(adapter.requests, hasLength(1)); + expect(result?.version, 1); + }); + }); + + group('seed hydration', () { + const seedConfig = StacBundleConfig( + enabled: true, + prefetchOnInit: false, + seedAsset: 'assets/stac_bundle.json', + ); + + /// Seed bundle body as written by `stac deploy` (no fetchedAt). + String seedJson({int version = 3, String projectId = projectId}) { + return jsonEncode({ + 'projectId': projectId, + 'version': version, + 'etag': '"$version"', + 'checksum': 'seed-checksum', + 'screens': {'home': '{"type":"text","data":"seed-v$version"}'}, + 'themes': {'light': '{"brightness":"seed"}'}, + }); + } + + test('empty store hydrates from the seed asset offline', () async { + StacBundleService.assetReader = (path) async { + expect(path, 'assets/stac_bundle.json'); + return seedJson(version: 3); + }; + await initStac(bundleConfig: seedConfig); + + final result = await StacBundleService.ensureLoaded(); + + expect(result?.version, 3); + expect( + await StacBundleService.getScreenJson('home'), + '{"type":"text","data":"seed-v3"}', + ); + // No network call was made at any point. + expect(adapter.requests, isEmpty); + + // The seed won, so it was persisted for the next launch. + final persisted = await const SharedPreferencesBundleStore().read( + projectId, + ); + expect(persisted?.version, 3); + expect(persisted?.etag, '"3"'); + }); + + test('newer stored bundle wins over an older seed', () async { + await const SharedPreferencesBundleStore().write( + storedBundle(version: 2), + ); + final spy = _SpyBundleStore(); + StacBundleService.store = spy; + StacBundleService.assetReader = (path) async => seedJson(version: 1); + await initStac(bundleConfig: seedConfig); + + final result = await StacBundleService.ensureLoaded(); + + expect(result?.version, 2); + expect(await StacBundleService.getScreenJson('home'), contains('v2')); + expect(spy.writeCount, 0); + expect(adapter.requests, isEmpty); + }); + + test('newer seed wins over an older stored bundle and is persisted', () async { + await const SharedPreferencesBundleStore().write( + storedBundle(version: 1), + ); + StacBundleService.assetReader = (path) async => seedJson(version: 3); + await initStac(bundleConfig: seedConfig); + + final result = await StacBundleService.ensureLoaded(); + + expect(result?.version, 3); + expect( + await StacBundleService.getScreenJson('home'), + '{"type":"text","data":"seed-v3"}', + ); + expect(adapter.requests, isEmpty); + + final persisted = await const SharedPreferencesBundleStore().read( + projectId, + ); + expect(persisted?.version, 3); + }); + + test('missing seed asset is tolerated', () async { + StacBundleService.assetReader = (path) async { + throw FlutterError('Unable to load asset: $path'); + }; + adapter.handler = (options) => jsonResponse(bundleBody(version: 1)); + await initStac(bundleConfig: seedConfig); + + final result = await StacBundleService.ensureLoaded(); + + // Falls back to a network sync as if no seed were configured. + expect(result?.version, 1); + expect(adapter.requests, hasLength(1)); + }); + + test('seed for another project is ignored', () async { + StacBundleService.assetReader = (path) async => + seedJson(version: 9, projectId: 'other_project'); + adapter.handler = (options) => jsonResponse(bundleBody(version: 1)); + await initStac(bundleConfig: seedConfig); + + final result = await StacBundleService.ensureLoaded(); + + expect(result?.version, 1); + expect(adapter.requests, hasLength(1)); + }); + }); + + group('StacBundleUpdater', () { + test('resume triggers a conditional sync', () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + adapter.handler = (options) => statusResponse(304); + await initStac(); + + final updater = StacBundleUpdater.instance; + expect(updater, isNotNull); + + updater!.didChangeAppLifecycleState(AppLifecycleState.resumed); + await pumpUntil(() => adapter.requests.isNotEmpty); + + final request = adapter.requests.single; + expect(request.uri.queryParameters['since'], '1'); + expect(headerOf(request, 'If-None-Match'), '"1"'); + }); + + test('resume does not sync when checkOnResume is off', () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + await initStac( + bundleConfig: const StacBundleConfig( + enabled: true, + prefetchOnInit: false, + checkOnResume: false, + ), + ); + + StacBundleUpdater.instance!.didChangeAppLifecycleState( + AppLifecycleState.resumed, + ); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(adapter.requests, isEmpty); + }); + + test('polling interval triggers conditional syncs and pause cancels them', () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + adapter.handler = (options) => statusResponse(304); + await initStac( + bundleConfig: const StacBundleConfig( + enabled: true, + prefetchOnInit: false, + checkOnResume: false, + pollingInterval: Duration(milliseconds: 20), + ), + ); + + await pumpUntil(() => adapter.requests.isNotEmpty); + expect(adapter.requests, isNotEmpty); + + // Pausing cancels the timer: no further requests come in. + StacBundleUpdater.instance!.didChangeAppLifecycleState( + AppLifecycleState.paused, + ); + await Future.delayed(const Duration(milliseconds: 30)); + final countAfterPause = adapter.requests.length; + await Future.delayed(const Duration(milliseconds: 60)); + expect(adapter.requests.length, countAfterPause); + }); + }); + + group('clear', () { + test('removes the persisted and in-memory bundle', () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + await initStac(); + + expect(await StacBundleService.ensureLoaded(), isNotNull); + + await StacBundleService.clear(); + + expect(StacBundleService.current, isNull); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('stac_bundle_$projectId'), isNull); + expect(prefs.getInt('stac_bundle_schema_$projectId'), isNull); + }); + }); +} diff --git a/packages/stac_cli/CHANGELOG.md b/packages/stac_cli/CHANGELOG.md index 3f8a9ad35..9d6c6c4f5 100644 --- a/packages/stac_cli/CHANGELOG.md +++ b/packages/stac_cli/CHANGELOG.md @@ -1,3 +1,10 @@ +## 1.7.0 + +- feat: Bundle deploys — `stac deploy` now publishes all screens and themes in a single atomic `POST /bundles` request (any failure exits non-zero with nothing partially applied). +- feat: Write a seed bundle to `assets/stac_bundle.json` after every successful deploy so first app launches can hydrate instantly and offline (warns when the asset is not declared in pubspec.yaml). +- feat: Add `--legacy` flag to `stac deploy` to fall back to the previous per-file screen/theme uploads. +- fix: `stac build` clears previous `screens`/`themes` outputs before writing, so deleted screens no longer leave stale JSON behind that gets re-deployed. + ## 1.6.0 - feat: Add console URL logging for successful deployments. diff --git a/packages/stac_cli/lib/src/commands/deploy_command.dart b/packages/stac_cli/lib/src/commands/deploy_command.dart index 5d294f164..18435eec2 100644 --- a/packages/stac_cli/lib/src/commands/deploy_command.dart +++ b/packages/stac_cli/lib/src/commands/deploy_command.dart @@ -31,12 +31,19 @@ class DeployCommand extends BaseCommand { help: 'Skip building before deployment (deploy existing files)', negatable: false, ); + argParser.addFlag( + 'legacy', + help: + 'Use the legacy per-file screen/theme uploads instead of the atomic bundle deploy', + negatable: false, + ); } @override Future execute() async { final projectPath = argResults?['project'] as String?; final skipBuild = argResults?['skip-build'] as bool? ?? false; + final legacy = argResults?['legacy'] as bool? ?? false; try { // Build before deploying unless --skip-build is specified @@ -58,7 +65,7 @@ class DeployCommand extends BaseCommand { } // Deploy the built files - await _deployService.deploy(projectPath: projectPath); + await _deployService.deploy(projectPath: projectPath, legacy: legacy); return 0; } catch (e) { ConsoleLogger.error('Deployment failed: $e'); diff --git a/packages/stac_cli/lib/src/services/build_service.dart b/packages/stac_cli/lib/src/services/build_service.dart index c9bd20b66..01babeb02 100644 --- a/packages/stac_cli/lib/src/services/build_service.dart +++ b/packages/stac_cli/lib/src/services/build_service.dart @@ -30,10 +30,13 @@ class BuildService { final outputDirPath = path.join(projectDir, options.outputDir); await Directory(outputDirPath).create(recursive: true); - // Clear the output directory before generating new files - await _clearOutputDirectory(outputDirPath); + // Clear previous screen/theme outputs before generating new files so a + // deleted DSL screen or theme can't leave a stale JSON behind that gets + // re-deployed with every bundle. Only these two directories are cleared. final screensOutputDir = path.join(outputDirPath, 'screens'); final themesOutputDir = path.join(outputDirPath, 'themes'); + await _clearOutputDirectory(screensOutputDir); + await _clearOutputDirectory(themesOutputDir); await Directory(screensOutputDir).create(recursive: true); await Directory(themesOutputDir).create(recursive: true); diff --git a/packages/stac_cli/lib/src/services/deploy_service.dart b/packages/stac_cli/lib/src/services/deploy_service.dart index b7420f5ae..a6089acf8 100644 --- a/packages/stac_cli/lib/src/services/deploy_service.dart +++ b/packages/stac_cli/lib/src/services/deploy_service.dart @@ -1,5 +1,8 @@ +import 'dart:convert'; import 'dart:io'; +import 'package:crypto/crypto.dart'; +import 'package:dio/dio.dart'; import 'package:path/path.dart' as path; import '../config/env.dart'; @@ -10,12 +13,26 @@ import '../utils/http_client.dart'; /// Service for deploying Stac JSON files to the cloud class DeployService { - final HttpClientService _httpClient = HttpClientService.instance; + /// Creates a deploy service. + /// + /// [httpClient] is injectable for testing; when omitted the shared + /// [HttpClientService.instance] is used (resolved lazily so constructing + /// the service never touches the network configuration). + DeployService({HttpClientService? httpClient}) + : _httpClientOverride = httpClient; - /// Deploy all built JSON files (from stac/.build) to the Screens API - /// - Reads projectId from lib/default_stac_options.dart - /// - For each {name}.json in stac/.build, POST to Cloud Function "screens" - Future deploy({String? projectPath}) async { + final HttpClientService? _httpClientOverride; + + HttpClientService get _httpClient => + _httpClientOverride ?? HttpClientService.instance; + + /// Deploy all built JSON files (from stac/.build) to the cloud. + /// + /// Default: assembles all screens and themes into a single bundle and + /// publishes it atomically with one POST to the Bundles API — any failure + /// leaves the project untouched. Pass [legacy] to use the previous + /// per-file `/screens` + `/themes` uploads instead. + Future deploy({String? projectPath, bool legacy = false}) async { final projectDir = projectPath ?? Directory.current.path; // Read projectId from default_stac_options.dart @@ -35,6 +52,266 @@ class DeployService { ); } + if (legacy) { + await _deployLegacy(projectId: projectId, buildDirPath: buildDirPath); + return; + } + + await _deployBundle( + projectDir: projectDir, + projectId: projectId, + buildDirPath: buildDirPath, + ); + } + + /// Computes the advisory sha256 checksum for a bundle. + /// + /// The digest is taken over a deterministic JSON document — + /// `{projectId, screens, themes}` with fixed top-level key order and + /// screen/theme map keys sorted — so the result is stable regardless of + /// map insertion order. The server recomputes and owns the authoritative + /// checksum; this value is advisory (used for the server-side no-op check) + /// and a mismatch alone never fails a deploy. + static String computeBundleChecksum({ + required String projectId, + required Map screens, + required Map themes, + }) { + Map sortByKey(Map map) { + final keys = map.keys.toList()..sort(); + return {for (final key in keys) key: map[key]!}; + } + + final canonicalJson = jsonEncode({ + 'projectId': projectId, + 'screens': sortByKey(screens), + 'themes': sortByKey(themes), + }); + return sha256.convert(utf8.encode(canonicalJson)).toString(); + } + + /// Publish all built screens/themes as a single atomic bundle. + Future _deployBundle({ + required String projectDir, + required String projectId, + required String buildDirPath, + }) async { + ConsoleLogger.info('Deploying bundle to cloud...'); + ConsoleLogger.debug('Project ID: $projectId'); + + final screens = await _readArtifactDirectory( + path.join(buildDirPath, 'screens'), + label: 'screen', + ); + final themes = await _readArtifactDirectory( + path.join(buildDirPath, 'themes'), + label: 'theme', + ); + + if (screens.isEmpty && themes.isEmpty) { + throw StacException( + 'No built screens or themes found in $buildDirPath. Run "stac build" first.', + ); + } + + final checksum = computeBundleChecksum( + projectId: projectId, + screens: screens, + themes: themes, + ); + + final bundlesApiUrl = _resolveBundlesApiUrl(); + ConsoleLogger.debug('Bundles API: $bundlesApiUrl'); + + final payload = { + 'projectId': projectId, + 'checksum': checksum, + 'screens': screens, + 'themes': themes, + }; + final payloadBytes = utf8.encode(jsonEncode(payload)).length; + ConsoleLogger.info( + 'Bundle contents: ${screens.length} screen(s), ${themes.length} theme(s) — payload ${_formatBytes(payloadBytes)} ($payloadBytes bytes)', + ); + + Response response; + try { + response = await _httpClient.post(bundlesApiUrl, data: payload); + } on StacException { + rethrow; + } catch (e) { + throw StacException('Bundle deploy failed: $e'); + } + + final status = response.statusCode; + if (status != 200 && status != 201) { + throw StacException( + 'Bundle deploy failed: unexpected response (${status ?? 'no-status'}) from $bundlesApiUrl.', + ); + } + + final body = _decodeResponseBody(response.data); + final version = body['version']; + + // The server's checksum is authoritative — ours is advisory only. + final serverChecksum = body['checksum']?.toString(); + if (serverChecksum != null && serverChecksum != checksum) { + ConsoleLogger.debug( + 'Local checksum ($checksum) differs from server checksum ($serverChecksum). Using the server value.', + ); + } + + if (status == 201) { + ConsoleLogger.success('✓ Deployed bundle v$version'); + } else { + ConsoleLogger.info('No changes — bundle v$version already current'); + } + + await _writeSeedAsset( + projectDir: projectDir, + projectId: projectId, + responseBody: body, + screens: screens, + themes: themes, + ); + + final consoleUrl = 'https://console.stac.dev/project/$projectId'; + ConsoleLogger.info( + 'Open your project in the Stac Console to inspect your screens and themes: $consoleUrl', + ); + } + + /// Read every `*.json` file in [dirPath] into a map keyed by the file name + /// without its `.json` extension. + Future> _readArtifactDirectory( + String dirPath, { + required String label, + }) async { + final artifacts = {}; + final dir = Directory(dirPath); + if (!await dir.exists()) { + ConsoleLogger.debug('No $label output found at $dirPath. Skipping.'); + return artifacts; + } + + await for (final entity in dir.list()) { + if (entity is! File || !entity.path.endsWith('.json')) continue; + final name = path.basenameWithoutExtension(entity.path); + artifacts[name] = await entity.readAsString(); + } + return artifacts; + } + + /// Write the just-published bundle body to `/assets/stac_bundle.json` + /// so first app launches can hydrate instantly (and offline) from the seed. + /// + /// `version`/`etag`/`checksum` come from the deploy response — the server + /// is authoritative; a locally invented version is never written. + Future _writeSeedAsset({ + required String projectDir, + required String projectId, + required Map responseBody, + required Map screens, + required Map themes, + }) async { + final assetsDirPath = path.join(projectDir, 'assets'); + final seedPath = path.join(assetsDirPath, 'stac_bundle.json'); + + try { + await Directory(assetsDirPath).create(recursive: true); + final seed = { + 'projectId': projectId, + 'version': responseBody['version'], + 'etag': responseBody['etag'], + 'checksum': responseBody['checksum'], + 'screens': screens, + 'themes': themes, + }; + await File(seedPath).writeAsString(jsonEncode(seed)); + ConsoleLogger.info( + 'Seed bundle written to ${path.relative(seedPath, from: projectDir)}', + ); + } catch (e) { + // The bundle is already live server-side; a seed write failure must not + // turn a successful deploy into a failure — warn and move on. + ConsoleLogger.warning('Could not write seed bundle to $seedPath: $e'); + return; + } + + await _warnIfSeedAssetNotDeclared(projectDir); + } + + /// Warn when `assets/stac_bundle.json` is not declared under + /// `flutter/assets` in the app's pubspec.yaml. Never modifies the pubspec. + Future _warnIfSeedAssetNotDeclared(String projectDir) async { + const assetEntry = 'assets/stac_bundle.json'; + final pubspecPath = path.join(projectDir, 'pubspec.yaml'); + + var declared = false; + try { + final pubspec = await FileUtils.readYamlFile(pubspecPath); + final flutterSection = pubspec?['flutter']; + if (flutterSection is Map) { + final assets = flutterSection['assets']; + if (assets is List) { + declared = assets.any((entry) { + final value = entry?.toString().trim(); + return value == assetEntry || + value == 'assets/' || + value == 'assets'; + }); + } + } + } catch (e) { + ConsoleLogger.debug('Could not parse $pubspecPath: $e'); + } + + if (declared) return; + ConsoleLogger.warning( + 'The seed bundle is not declared as a Flutter asset in pubspec.yaml.\n' + ' Add it so first launches can render instantly (and offline):\n' + ' flutter:\n' + ' assets:\n' + ' - $assetEntry', + ); + } + + Map _decodeResponseBody(dynamic data) { + try { + dynamic decoded = data; + if (decoded is String && decoded.isNotEmpty) { + decoded = jsonDecode(decoded); + } + if (decoded is Map) { + final map = Map.from(decoded); + // Tolerate `{..., data: {...}}` envelopes. + if (map['version'] == null && map['data'] is Map) { + return Map.from(map['data'] as Map); + } + return map; + } + } catch (e) { + ConsoleLogger.debug('Could not parse bundle deploy response body: $e'); + } + return const {}; + } + + String _formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB'; + } + + // --------------------------------------------------------------------- + // Legacy per-file deploy (kept behind `stac deploy --legacy` for one + // release, then delete). + // --------------------------------------------------------------------- + + /// Deploy each built JSON file one at a time to the Screens/Themes APIs. + Future _deployLegacy({ + required String projectId, + required String buildDirPath, + }) async { ConsoleLogger.info('Deploying screens/themes to cloud...'); ConsoleLogger.debug('Project ID: $projectId'); @@ -182,6 +459,11 @@ class DeployService { return match?.group(1); } + /// Resolve Cloud Function endpoint for bundles.save + String _resolveBundlesApiUrl() { + return '${env.baseApiUrl}/bundles'; + } + /// Resolve Cloud Function endpoint for screens.save String _resolveScreensApiUrl() { // Use current environment's base URL + /screens endpoint diff --git a/packages/stac_cli/pubspec.lock b/packages/stac_cli/pubspec.lock index 4fe9fd6bf..3bc09704f 100644 --- a/packages/stac_cli/pubspec.lock +++ b/packages/stac_cli/pubspec.lock @@ -162,7 +162,7 @@ packages: source: hosted version: "1.15.0" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf diff --git a/packages/stac_cli/pubspec.yaml b/packages/stac_cli/pubspec.yaml index 89c523dde..0ee8d6c3d 100644 --- a/packages/stac_cli/pubspec.yaml +++ b/packages/stac_cli/pubspec.yaml @@ -18,6 +18,7 @@ dependencies: json_annotation: ^4.11.0 dotenv: ^4.2.0 cryptography: ^2.9.0 + crypto: ^3.0.3 # Executables that can be run globally executables: diff --git a/packages/stac_cli/test/services/deploy_service_test.dart b/packages/stac_cli/test/services/deploy_service_test.dart new file mode 100644 index 000000000..3123f61c2 --- /dev/null +++ b/packages/stac_cli/test/services/deploy_service_test.dart @@ -0,0 +1,300 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:path/path.dart' as p; +import 'package:stac_cli/src/config/env.dart'; +import 'package:stac_cli/src/exceptions/stac_exception.dart'; +import 'package:stac_cli/src/services/deploy_service.dart'; +import 'package:stac_cli/src/utils/http_client.dart'; +import 'package:test/test.dart'; + +const _projectId = 'test-project'; + +/// Hand-rolled fake for [HttpClientService]; records every POST and answers +/// with the injected handler. +class _FakeHttpClientService implements HttpClientService { + _FakeHttpClientService(this._onPost); + + final Future> Function(String url, dynamic data) _onPost; + + final List<({String url, dynamic data})> postCalls = []; + + @override + Future> post(String path, {dynamic data}) async { + postCalls.add((url: path, data: data)); + return _onPost(path, data); + } + + @override + Future> get( + String path, { + Map? queryParameters, + }) => throw UnimplementedError('GET is not expected in these tests'); + + @override + Future> put(String path, {dynamic data}) => + throw UnimplementedError('PUT is not expected in these tests'); + + @override + Future> delete(String path) => + throw UnimplementedError('DELETE is not expected in these tests'); +} + +Response _jsonResponse( + String url, + int statusCode, + Map body, +) { + return Response( + requestOptions: RequestOptions(path: url), + statusCode: statusCode, + data: body, + ); +} + +/// Creates a temporary fixture project with `lib/default_stac_options.dart` +/// and a populated `stac/.build` directory. Returns the project path. +Future _createFixtureProject({ + Map screens = const {}, + Map themes = const {}, +}) async { + final dir = await Directory.systemTemp.createTemp('stac_cli_deploy_test_'); + final projectDir = dir.path; + + final optionsFile = File( + p.join(projectDir, 'lib', 'default_stac_options.dart'), + ); + await optionsFile.create(recursive: true); + await optionsFile.writeAsString(''' +const defaultStacOptions = StacOptions( + name: 'Test', + description: 'Test project', + projectId: '$_projectId', + sourceDir: 'stac', + outputDir: 'stac/.build', +); +'''); + + await Directory(p.join(projectDir, 'stac', '.build')).create(recursive: true); + for (final entry in screens.entries) { + final file = File( + p.join(projectDir, 'stac', '.build', 'screens', '${entry.key}.json'), + ); + await file.create(recursive: true); + await file.writeAsString(entry.value); + } + for (final entry in themes.entries) { + final file = File( + p.join(projectDir, 'stac', '.build', 'themes', '${entry.key}.json'), + ); + await file.create(recursive: true); + await file.writeAsString(entry.value); + } + + addTearDown(() => dir.delete(recursive: true)); + return projectDir; +} + +void main() { + const screensFixture = { + 'home': '{"type":"scaffold","body":{"type":"text","data":"Home"}}', + 'profile': '{"type":"scaffold","body":{"type":"text","data":"Profile"}}', + }; + const themesFixture = {'light': '{"brightness":"light"}'}; + + setUpAll(() { + // Values are only used as fallbacks when the real variables are absent + // from the process environment (configureEnvironment keeps OS env wins). + configureEnvironment({ + 'STAC_BASE_API_URL': 'https://api.stac.test', + 'STAC_GOOGLE_CLIENT_ID': 'test-client-id', + 'STAC_FIREBASE_API_KEY': 'test-firebase-key', + }); + }); + + group('DeployService.deploy (bundle mode)', () { + test( + 'sends a single POST to /bundles with correctly assembled maps', + () async { + final projectDir = await _createFixtureProject( + screens: screensFixture, + themes: themesFixture, + ); + final client = _FakeHttpClientService( + (url, data) async => _jsonResponse(url, 201, { + 'projectId': _projectId, + 'version': 3, + 'etag': '"3"', + 'checksum': 'server-checksum', + 'deployedAt': '2026-07-03T00:00:00.000Z', + }), + ); + + await DeployService(httpClient: client).deploy(projectPath: projectDir); + + expect(client.postCalls, hasLength(1)); + final call = client.postCalls.single; + expect(call.url, '${env.baseApiUrl}/bundles'); + + final payload = call.data as Map; + expect(payload['projectId'], _projectId); + expect(payload['screens'], equals(screensFixture)); + expect(payload['themes'], equals(themesFixture)); + expect( + payload['checksum'], + DeployService.computeBundleChecksum( + projectId: _projectId, + screens: screensFixture, + themes: themesFixture, + ), + ); + }, + ); + + test('writes the seed asset using the server response version', () async { + final projectDir = await _createFixtureProject( + screens: screensFixture, + themes: themesFixture, + ); + final client = _FakeHttpClientService( + (url, data) async => _jsonResponse(url, 201, { + 'projectId': _projectId, + 'version': 7, + 'etag': '"7"', + 'checksum': 'authoritative-server-checksum', + 'deployedAt': '2026-07-03T00:00:00.000Z', + }), + ); + + await DeployService(httpClient: client).deploy(projectPath: projectDir); + + final seedFile = File(p.join(projectDir, 'assets', 'stac_bundle.json')); + expect(seedFile.existsSync(), isTrue); + + final seed = + jsonDecode(await seedFile.readAsString()) as Map; + expect(seed['projectId'], _projectId); + expect(seed['version'], 7); + expect(seed['etag'], '"7"'); + expect(seed['checksum'], 'authoritative-server-checksum'); + expect(seed['screens'], equals(screensFixture)); + expect(seed['themes'], equals(themesFixture)); + }); + + test( + 'treats a 200 noop response as success and refreshes the seed', + () async { + final projectDir = await _createFixtureProject( + screens: screensFixture, + themes: themesFixture, + ); + final client = _FakeHttpClientService( + (url, data) async => _jsonResponse(url, 200, { + 'projectId': _projectId, + 'version': 5, + 'etag': '"5"', + 'checksum': 'unchanged-checksum', + 'noop': true, + }), + ); + + await DeployService(httpClient: client).deploy(projectPath: projectDir); + + expect(client.postCalls, hasLength(1)); + final seedFile = File(p.join(projectDir, 'assets', 'stac_bundle.json')); + final seed = + jsonDecode(await seedFile.readAsString()) as Map; + expect(seed['version'], 5); + }, + ); + + test('throws StacException on a non-2xx response', () async { + final projectDir = await _createFixtureProject( + screens: screensFixture, + themes: themesFixture, + ); + final client = _FakeHttpClientService( + (url, data) async => + _jsonResponse(url, 500, {'error': 'internal error'}), + ); + + await expectLater( + DeployService(httpClient: client).deploy(projectPath: projectDir), + throwsA(isA()), + ); + + // Atomic: nothing was applied, so no seed asset is written either. + expect( + File(p.join(projectDir, 'assets', 'stac_bundle.json')).existsSync(), + isFalse, + ); + }); + + test('propagates HTTP-layer failures as StacException', () async { + final projectDir = await _createFixtureProject( + screens: screensFixture, + themes: themesFixture, + ); + final client = _FakeHttpClientService( + (url, data) async => throw StacException( + 'HTTP request failed (503) for $url: service unavailable', + ), + ); + + await expectLater( + DeployService(httpClient: client).deploy(projectPath: projectDir), + throwsA(isA()), + ); + }); + + test('throws StacException when there is nothing to deploy', () async { + final projectDir = await _createFixtureProject(); + final client = _FakeHttpClientService( + (url, data) async => _jsonResponse(url, 201, const {}), + ); + + await expectLater( + DeployService(httpClient: client).deploy(projectPath: projectDir), + throwsA(isA()), + ); + expect(client.postCalls, isEmpty); + }); + }); + + group('DeployService.computeBundleChecksum', () { + test('is stable across map insertion orders', () { + final a = DeployService.computeBundleChecksum( + projectId: _projectId, + screens: {'a': '1', 'b': '2'}, + themes: {'x': '9', 'y': '8'}, + ); + final b = DeployService.computeBundleChecksum( + projectId: _projectId, + screens: {'b': '2', 'a': '1'}, + themes: {'y': '8', 'x': '9'}, + ); + expect(a, b); + }); + + test('changes when content changes', () { + final a = DeployService.computeBundleChecksum( + projectId: _projectId, + screens: {'a': '1'}, + themes: {}, + ); + final b = DeployService.computeBundleChecksum( + projectId: _projectId, + screens: {'a': '2'}, + themes: {}, + ); + final c = DeployService.computeBundleChecksum( + projectId: 'other-project', + screens: {'a': '1'}, + themes: {}, + ); + expect(a, isNot(b)); + expect(a, isNot(c)); + }); + }); +}