feat: offline store foundation — manifest, snapshots, refcounted delete (offline phase 1/6) #84

Merged
bjoern merged 2 commits from feat/offline-library into main 2026-09-01 23:03:55 +02:00
Member

Phase 1 of 6 of the offline/download feature (planned with Björn; decisions locked: quality follows maxImageSizeKb, per-variant downloads only, one /offline page with Downloads/Library tabs, no "Downloaded" chip in the main grid). Storage foundation only — no UI, no state wiring yet.

What's in

  • OfflineManifest entities (domain/entities/): doujins → variants → pages, strict fromJson (FormatException on malformed shapes), per-download quality fields (compressed/maxKb).
  • OfflineStore port (domain/repositories/offline_repository.dart): manifest read/write, content-addressed image files, model snapshots, delete(doujinId, {variantId?}) with refcounting.
  • FilesystemOfflineStore (data/offline/): <config>/offline/ with index.json, images/<imageFileId><ext>, snapshots/<id>.json, covers/. Missing/corrupt index → empty manifest (same contract as ReadingProgressStore); null base dir (web) → no-op; delete refcounts image ids across downloaded variants (server content-dedup means one file can back pages in several variants), doujin entry + cover die with the last variant.

Verification

  • flutter test test/offline_store_test.dart: 15/15 — manifest round-trips (incl. absent maxKb key for originals), corruption tolerance ×3, image/snapshot IO, refcount delete ×4 (shared image survives one variant's delete, dies with last), null-dir no-op.
  • Full suite: 584/584, flutter analyze: no issues.
  • Mutation probes (restored after each): refcount-disabled → 2 red; corruption-rethrow → 2 red; write-no-op → 3 red.

Phases 2–6 (Redux queue + download service, download manager view, library view, offline reader fallback, docs) follow on this branch as separate commits, reviewed phase by phase.

Phase 1 of 6 of the offline/download feature (planned with Björn; decisions locked: quality follows `maxImageSizeKb`, per-variant downloads only, one `/offline` page with Downloads/Library tabs, no "Downloaded" chip in the main grid). Storage foundation only — no UI, no state wiring yet. ## What's in - **`OfflineManifest` entities** (`domain/entities/`): doujins → variants → pages, strict `fromJson` (FormatException on malformed shapes), per-download quality fields (`compressed`/`maxKb`). - **`OfflineStore` port** (`domain/repositories/offline_repository.dart`): manifest read/write, content-addressed image files, model snapshots, `delete(doujinId, {variantId?})` with refcounting. - **`FilesystemOfflineStore`** (`data/offline/`): `<config>/offline/` with `index.json`, `images/<imageFileId><ext>`, `snapshots/<id>.json`, `covers/`. Missing/corrupt index → empty manifest (same contract as `ReadingProgressStore`); null base dir (web) → no-op; delete refcounts image ids across downloaded variants (server content-dedup means one file can back pages in several variants), doujin entry + cover die with the last variant. ## Verification - `flutter test test/offline_store_test.dart`: 15/15 — manifest round-trips (incl. absent `maxKb` key for originals), corruption tolerance ×3, image/snapshot IO, refcount delete ×4 (shared image survives one variant's delete, dies with last), null-dir no-op. - Full suite: **584/584**, `flutter analyze`: no issues. - Mutation probes (restored after each): refcount-disabled → 2 red; corruption-rethrow → 2 red; write-no-op → 3 red. Phases 2–6 (Redux queue + download service, download manager view, library view, offline reader fallback, docs) follow on this branch as separate commits, reviewed phase by phase.
feat: offline store foundation — manifest, snapshots, refcounted delete
All checks were successful
Flutter CI / analyze-and-test (pull_request) Successful in 3m12s
75b9f4a76e
Phase 1 of the offline/download feature (plan:
2026-09-01_191200-doujin-manager-offline-feature). No UI yet.

- OfflineManifest entities (OfflineDoujin/OfflineVariant/OfflinePage):
  strict fromJson that throws FormatException on malformed shapes,
  quality fields (compressed/maxKb) per download.
- OfflineStore port: manifest read/write, content-addressed image
  files, model snapshots, delete with refcounting.
- FilesystemOfflineStore under <config>/offline/: index.json +
  images/<imageFileId><ext> + snapshots/<id>.json + covers/.
  Corrupted or missing index yields an empty manifest (same contract
  as ReadingProgressStore); null base dir (web) degrades to no-op.
  Delete refcounts image ids across downloaded variants — shared
  files survive until the last referencing variant is removed; the
  doujin entry and cover die with the last variant.

Tests (15): manifest round-trips (incl. absent maxKb key), corruption
tolerance x3, image/snapshot IO, refcount delete x4, null-dir no-op.
Mutation-probed: refcount-disabled, corruption-rethrow, and
write-no-op mutations each turn exactly the covering tests red.

Flutter Coverage

File Line coverage
lib/app/store.dart 100.0% (19 of 19)
lib/data/models/doujin_models.dart 85.0% (34 of 40)
lib/data/models/doujin_models.g.dart 40.5% (119 of 294)
lib/domain/entities/stored_settings.dart 100.0% (9 of 9)
lib/presentation/state/app_state.dart 72.7% (16 of 22)
lib/agent/agent_service.dart 88.7% (344 of 388)
lib/agent/approval_gate.dart 100.0% (16 of 16)
lib/agent/assistant_context.dart 52.8% (28 of 53)
lib/agent/browser_budget.dart 80.0% (4 of 5)
lib/agent/caching_describer.dart 100.0% (13 of 13)
lib/agent/memory_store.dart 92.9% (13 of 14)
lib/agent/skills/skill_registry.dart 98.2% (54 of 55)
lib/agent/system_prompt.dart 100.0% (66 of 66)
lib/agent/tools/budgeted_browser_tool.dart 84.2% (16 of 19)
lib/agent/tools/doujin_write_tool.dart 62.9% (168 of 267)
lib/agent/tools/entity_write_tool.dart 73.5% (164 of 223)
lib/agent/tools/fetch_page_tool.dart 89.3% (67 of 75)
lib/agent/tools/get_doujin_tool.dart 84.4% (27 of 32)
lib/agent/tools/list_entities_tool.dart 76.1% (54 of 71)
lib/agent/tools/navigate_tool.dart 93.8% (30 of 32)
lib/agent/tools/read_skill_tool.dart 82.4% (14 of 17)
lib/agent/tools/reflection_tools.dart 75.7% (28 of 37)
lib/agent/tools/search_doujins_tool.dart 100.0% (84 of 84)
lib/agent/tools/view_images_tool.dart 95.0% (38 of 40)
lib/domain/entities/assistant_entry.dart 20.0% (1 of 5)
lib/presentation/state/actions/assistant_actions.dart 54.5% (6 of 11)
lib/core/app_config.dart 70.0% (7 of 10)
lib/domain/entities/entity_model.dart 100.0% (1 of 1)
lib/data/repositories/entity_in_use_exception.dart 33.3% (1 of 3)
lib/data/models/search_query.dart 50.0% (2 of 4)
lib/data/models/search_query.g.dart 33.3% (25 of 75)
lib/core/constants.dart 36.4% (4 of 11)
lib/data/reading_progress_store.dart 88.6% (31 of 35)
lib/domain/entities/reading_progress.dart 100.0% (26 of 26)
lib/presentation/middleware/assistant_epics.dart 83.1% (74 of 89)
lib/presentation/middleware/epics.dart 90.1% (347 of 385)
lib/presentation/state/reducers.dart 100.0% (11 of 11)
lib/core/chunking.dart 100.0% (12 of 12)
lib/core/languages.dart 100.0% (8 of 8)
lib/data/models/envelope.dart 81.2% (13 of 16)
lib/data/models/envelope.g.dart 50.7% (34 of 67)
lib/data/repositories/upload_exception.dart 33.3% (1 of 3)
lib/domain/entities/filter_token.dart 90.9% (90 of 99)
lib/presentation/middleware/editor_epics.dart 56.6% (163 of 288)
lib/presentation/middleware/entity_ops.dart 58.0% (40 of 69)
lib/presentation/middleware/upload_epics.dart 98.1% (104 of 106)
lib/presentation/state/actions/detail_actions.dart 85.7% (6 of 7)
lib/presentation/state/actions/editor_actions.dart 45.9% (17 of 37)
lib/presentation/state/actions/entity_actions.dart 62.5% (10 of 16)
lib/presentation/state/actions/library_actions.dart 42.9% (9 of 21)
lib/presentation/state/actions/metadata_actions.dart 100.0% (5 of 5)
lib/presentation/state/actions/reader_actions.dart 75.0% (3 of 4)
lib/presentation/state/actions/reading_progress_actions.dart 100.0% (2 of 2)
lib/presentation/state/actions/settings_actions.dart 87.5% (7 of 8)
lib/presentation/state/actions/upload_actions.dart 90.9% (10 of 11)
lib/presentation/state/reducers/assistant_reducer.dart 95.0% (57 of 60)
lib/presentation/state/reducers/detail_reducer.dart 95.8% (23 of 24)
lib/presentation/state/reducers/editor_reducer.dart 92.5% (98 of 106)
lib/presentation/state/reducers/entity_reducer.dart 98.8% (82 of 83)
lib/presentation/state/reducers/library_reducer.dart 100.0% (117 of 117)
lib/presentation/state/reducers/metadata_reducer.dart 90.5% (38 of 42)
lib/presentation/state/reducers/reader_reducer.dart 100.0% (15 of 15)
lib/presentation/state/reducers/reading_progress_reducer.dart 100.0% (17 of 17)
lib/presentation/state/reducers/settings_reducer.dart 100.0% (60 of 60)
lib/presentation/state/reducers/upload_reducer.dart 100.0% (45 of 45)
lib/presentation/pages/editor/page_grid.dart 89.4% (286 of 320)
lib/presentation/pages/editor/variants_tab.dart 55.3% (52 of 94)
lib/core/natural_sort.dart 100.0% (27 of 27)
lib/core/url_utils.dart 100.0% (4 of 4)
lib/presentation/pages/editor/chapter_panel.dart 11.8% (9 of 76)
lib/presentation/widgets/cover_thumbnail.dart 83.3% (30 of 36)
lib/presentation/pages/editor/upload_panel.dart 38.6% (61 of 158)
lib/presentation/pages/editor/variant_dialog.dart 4.5% (3 of 66)
lib/presentation/widgets/language_dropdown.dart 84.6% (11 of 13)
lib/presentation/pages/reader/reader_page.dart 89.0% (227 of 255)
lib/presentation/pages/detail/detail_page.dart 77.1% (178 of 231)
lib/presentation/pages/detail/variant_tabs_panel.dart 95.4% (186 of 195)
lib/presentation/widgets/star_rating.dart 100.0% (72 of 72)
lib/presentation/pages/reader/reader_overlay.dart 94.4% (51 of 54)
lib/presentation/pages/reader/reader_sequence.dart 100.0% (27 of 27)
lib/app/di.dart 48.3% (14 of 29)
lib/presentation/assistant/assistant_panel.dart 91.3% (84 of 92)
lib/presentation/layout/main_layout.dart 86.3% (44 of 51)
lib/data/api_client.dart 92.8% (64 of 69)
lib/data/repositories/doujin_api_repository.dart 23.0% (84 of 365)
lib/data/repositories/health_repository.dart 72.0% (18 of 25)
lib/data/secure_storage.dart 0.0% (0 of 26)
lib/core/theme.dart 96.9% (31 of 32)
lib/presentation/assistant/approval_card.dart 95.0% (38 of 40)
lib/presentation/assistant/assistant_markdown.dart 100.0% (3 of 3)
lib/presentation/assistant/chat_entries.dart 87.5% (35 of 40)
lib/presentation/pages/people/people_page.dart 55.6% (10 of 18)
lib/presentation/widgets/entity_editor.dart 91.6% (131 of 143)
lib/presentation/widgets/entity_management_page.dart 86.8% (217 of 250)
lib/presentation/pages/editor/editor_page.dart 77.6% (59 of 76)
lib/presentation/pages/editor/association_picker.dart 95.5% (106 of 111)
lib/presentation/pages/editor/associations_tab.dart 73.8% (90 of 122)
lib/presentation/pages/editor/doujin_list_pane.dart 67.2% (43 of 64)
lib/presentation/pages/editor/edit_title_dialog.dart 91.7% (55 of 60)
lib/presentation/pages/editor/editor_pane.dart 70.9% (39 of 55)
lib/presentation/pages/editor/new_doujin_dialog.dart 66.7% (30 of 45)
lib/presentation/pages/editor/metadata_tab.dart 70.5% (93 of 132)
lib/presentation/pages/characters/characters_page.dart 57.9% (11 of 19)
lib/presentation/pages/tags/tags_page.dart 100.0% (17 of 17)
lib/data/offline/filesystem_offline_store.dart 93.5% (101 of 108)
lib/domain/entities/offline_manifest.dart 100.0% (78 of 78)
lib/domain/repositories/offline_repository.dart 0.0% (0 of 1)
lib/app/app.dart 66.7% (44 of 66)
lib/presentation/pages/settings/settings_page.dart 99.3% (138 of 139)
lib/presentation/pages/circles/circles_page.dart 52.6% (10 of 19)
lib/presentation/pages/library/library_page.dart 90.6% (211 of 233)
lib/presentation/pages/series/series_page.dart 50.0% (9 of 18)
lib/presentation/widgets/smart_filter_bar.dart 79.7% (181 of 227)
lib/presentation/widgets/model_combo_field.dart 69.0% (100 of 145)
lib/app/skill_assets.dart 92.9% (13 of 14)

Total: 76.5% (6472 of 8465)

<!-- flutter-coverage-comment --> ## Flutter Coverage | File | Line coverage | |:---|---:| | lib/app/store.dart | 100.0% (19 of 19) | | lib/data/models/doujin_models.dart | 85.0% (34 of 40) | | lib/data/models/doujin_models.g.dart | 40.5% (119 of 294) | | lib/domain/entities/stored_settings.dart | 100.0% (9 of 9) | | lib/presentation/state/app_state.dart | 72.7% (16 of 22) | | lib/agent/agent_service.dart | 88.7% (344 of 388) | | lib/agent/approval_gate.dart | 100.0% (16 of 16) | | lib/agent/assistant_context.dart | 52.8% (28 of 53) | | lib/agent/browser_budget.dart | 80.0% (4 of 5) | | lib/agent/caching_describer.dart | 100.0% (13 of 13) | | lib/agent/memory_store.dart | 92.9% (13 of 14) | | lib/agent/skills/skill_registry.dart | 98.2% (54 of 55) | | lib/agent/system_prompt.dart | 100.0% (66 of 66) | | lib/agent/tools/budgeted_browser_tool.dart | 84.2% (16 of 19) | | lib/agent/tools/doujin_write_tool.dart | 62.9% (168 of 267) | | lib/agent/tools/entity_write_tool.dart | 73.5% (164 of 223) | | lib/agent/tools/fetch_page_tool.dart | 89.3% (67 of 75) | | lib/agent/tools/get_doujin_tool.dart | 84.4% (27 of 32) | | lib/agent/tools/list_entities_tool.dart | 76.1% (54 of 71) | | lib/agent/tools/navigate_tool.dart | 93.8% (30 of 32) | | lib/agent/tools/read_skill_tool.dart | 82.4% (14 of 17) | | lib/agent/tools/reflection_tools.dart | 75.7% (28 of 37) | | lib/agent/tools/search_doujins_tool.dart | 100.0% (84 of 84) | | lib/agent/tools/view_images_tool.dart | 95.0% (38 of 40) | | lib/domain/entities/assistant_entry.dart | 20.0% (1 of 5) | | lib/presentation/state/actions/assistant_actions.dart | 54.5% (6 of 11) | | lib/core/app_config.dart | 70.0% (7 of 10) | | lib/domain/entities/entity_model.dart | 100.0% (1 of 1) | | lib/data/repositories/entity_in_use_exception.dart | 33.3% (1 of 3) | | lib/data/models/search_query.dart | 50.0% (2 of 4) | | lib/data/models/search_query.g.dart | 33.3% (25 of 75) | | lib/core/constants.dart | 36.4% (4 of 11) | | lib/data/reading_progress_store.dart | 88.6% (31 of 35) | | lib/domain/entities/reading_progress.dart | 100.0% (26 of 26) | | lib/presentation/middleware/assistant_epics.dart | 83.1% (74 of 89) | | lib/presentation/middleware/epics.dart | 90.1% (347 of 385) | | lib/presentation/state/reducers.dart | 100.0% (11 of 11) | | lib/core/chunking.dart | 100.0% (12 of 12) | | lib/core/languages.dart | 100.0% (8 of 8) | | lib/data/models/envelope.dart | 81.2% (13 of 16) | | lib/data/models/envelope.g.dart | 50.7% (34 of 67) | | lib/data/repositories/upload_exception.dart | 33.3% (1 of 3) | | lib/domain/entities/filter_token.dart | 90.9% (90 of 99) | | lib/presentation/middleware/editor_epics.dart | 56.6% (163 of 288) | | lib/presentation/middleware/entity_ops.dart | 58.0% (40 of 69) | | lib/presentation/middleware/upload_epics.dart | 98.1% (104 of 106) | | lib/presentation/state/actions/detail_actions.dart | 85.7% (6 of 7) | | lib/presentation/state/actions/editor_actions.dart | 45.9% (17 of 37) | | lib/presentation/state/actions/entity_actions.dart | 62.5% (10 of 16) | | lib/presentation/state/actions/library_actions.dart | 42.9% (9 of 21) | | lib/presentation/state/actions/metadata_actions.dart | 100.0% (5 of 5) | | lib/presentation/state/actions/reader_actions.dart | 75.0% (3 of 4) | | lib/presentation/state/actions/reading_progress_actions.dart | 100.0% (2 of 2) | | lib/presentation/state/actions/settings_actions.dart | 87.5% (7 of 8) | | lib/presentation/state/actions/upload_actions.dart | 90.9% (10 of 11) | | lib/presentation/state/reducers/assistant_reducer.dart | 95.0% (57 of 60) | | lib/presentation/state/reducers/detail_reducer.dart | 95.8% (23 of 24) | | lib/presentation/state/reducers/editor_reducer.dart | 92.5% (98 of 106) | | lib/presentation/state/reducers/entity_reducer.dart | 98.8% (82 of 83) | | lib/presentation/state/reducers/library_reducer.dart | 100.0% (117 of 117) | | lib/presentation/state/reducers/metadata_reducer.dart | 90.5% (38 of 42) | | lib/presentation/state/reducers/reader_reducer.dart | 100.0% (15 of 15) | | lib/presentation/state/reducers/reading_progress_reducer.dart | 100.0% (17 of 17) | | lib/presentation/state/reducers/settings_reducer.dart | 100.0% (60 of 60) | | lib/presentation/state/reducers/upload_reducer.dart | 100.0% (45 of 45) | | lib/presentation/pages/editor/page_grid.dart | 89.4% (286 of 320) | | lib/presentation/pages/editor/variants_tab.dart | 55.3% (52 of 94) | | lib/core/natural_sort.dart | 100.0% (27 of 27) | | lib/core/url_utils.dart | 100.0% (4 of 4) | | lib/presentation/pages/editor/chapter_panel.dart | 11.8% (9 of 76) | | lib/presentation/widgets/cover_thumbnail.dart | 83.3% (30 of 36) | | lib/presentation/pages/editor/upload_panel.dart | 38.6% (61 of 158) | | lib/presentation/pages/editor/variant_dialog.dart | 4.5% (3 of 66) | | lib/presentation/widgets/language_dropdown.dart | 84.6% (11 of 13) | | lib/presentation/pages/reader/reader_page.dart | 89.0% (227 of 255) | | lib/presentation/pages/detail/detail_page.dart | 77.1% (178 of 231) | | lib/presentation/pages/detail/variant_tabs_panel.dart | 95.4% (186 of 195) | | lib/presentation/widgets/star_rating.dart | 100.0% (72 of 72) | | lib/presentation/pages/reader/reader_overlay.dart | 94.4% (51 of 54) | | lib/presentation/pages/reader/reader_sequence.dart | 100.0% (27 of 27) | | lib/app/di.dart | 48.3% (14 of 29) | | lib/presentation/assistant/assistant_panel.dart | 91.3% (84 of 92) | | lib/presentation/layout/main_layout.dart | 86.3% (44 of 51) | | lib/data/api_client.dart | 92.8% (64 of 69) | | lib/data/repositories/doujin_api_repository.dart | 23.0% (84 of 365) | | lib/data/repositories/health_repository.dart | 72.0% (18 of 25) | | lib/data/secure_storage.dart | 0.0% (0 of 26) | | lib/core/theme.dart | 96.9% (31 of 32) | | lib/presentation/assistant/approval_card.dart | 95.0% (38 of 40) | | lib/presentation/assistant/assistant_markdown.dart | 100.0% (3 of 3) | | lib/presentation/assistant/chat_entries.dart | 87.5% (35 of 40) | | lib/presentation/pages/people/people_page.dart | 55.6% (10 of 18) | | lib/presentation/widgets/entity_editor.dart | 91.6% (131 of 143) | | lib/presentation/widgets/entity_management_page.dart | 86.8% (217 of 250) | | lib/presentation/pages/editor/editor_page.dart | 77.6% (59 of 76) | | lib/presentation/pages/editor/association_picker.dart | 95.5% (106 of 111) | | lib/presentation/pages/editor/associations_tab.dart | 73.8% (90 of 122) | | lib/presentation/pages/editor/doujin_list_pane.dart | 67.2% (43 of 64) | | lib/presentation/pages/editor/edit_title_dialog.dart | 91.7% (55 of 60) | | lib/presentation/pages/editor/editor_pane.dart | 70.9% (39 of 55) | | lib/presentation/pages/editor/new_doujin_dialog.dart | 66.7% (30 of 45) | | lib/presentation/pages/editor/metadata_tab.dart | 70.5% (93 of 132) | | lib/presentation/pages/characters/characters_page.dart | 57.9% (11 of 19) | | lib/presentation/pages/tags/tags_page.dart | 100.0% (17 of 17) | | lib/data/offline/filesystem_offline_store.dart | 93.5% (101 of 108) | | lib/domain/entities/offline_manifest.dart | 100.0% (78 of 78) | | lib/domain/repositories/offline_repository.dart | 0.0% (0 of 1) | | lib/app/app.dart | 66.7% (44 of 66) | | lib/presentation/pages/settings/settings_page.dart | 99.3% (138 of 139) | | lib/presentation/pages/circles/circles_page.dart | 52.6% (10 of 19) | | lib/presentation/pages/library/library_page.dart | 90.6% (211 of 233) | | lib/presentation/pages/series/series_page.dart | 50.0% (9 of 18) | | lib/presentation/widgets/smart_filter_bar.dart | 79.7% (181 of 227) | | lib/presentation/widgets/model_combo_field.dart | 69.0% (100 of 145) | | lib/app/skill_assets.dart | 92.9% (13 of 14) | **Total: 76.5% (6472 of 8465)**
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh! A content-addressed offline store with refcounted deletion~ Content dedup on the server, one file on disk backing pages in several variants, and delete computes before − after on referenced image ids… that is elegant, matikane. I read all 830 lines, traced every branch of delete(), and reproduced your verification exactly — 584/584 tests pass, flutter analyze clean, your claims are honest down to the count. ♡

But fufu… then I fed your own corruption contract five little poisoned index files, and the app crashed on all five. You wouldn't leave THAT in production, would you? ♡

Verdict: ⛔ I can't let this pass~ ♡

⛔ These need fixing before I'm satisfied~

  1. offline_manifest.dart:86, :156, :199 + filesystem_offline_store.dart:45-50 — the corruption-tolerance contract is broken: a malformed index crashes instead of degrading. The store's doc promises "a missing or malformed index yields an empty manifest instead of crashing," and each entity's dartdoc promises "Throws a [FormatException] on unexpected value types." But entry.value! as Map<String, dynamic> (and page! as Map<...> at :86) throw TypeError (non-Map value) or "Null check operator used on a null value" (null value) — neither is a FormatException, so readManifest's on FileSystemException/on FormatException catch arms let it fly straight out. Verified with 5 probe tests in my review clone — all 5 RED: doujin value "corrupt", variant value "corrupt", page entry "corrupt", page entry null, doujin value null. Every one crashes readManifest(), i.e. crashes app startup. Your three corruption tests pass because they corrupt field types (the guarded is! checks) — the entry-value shapes were never exercised. Note the sibling you cite does guard this: ReadingProgressStore.read() (reading_progress_store.dart:44-47) checks value is! Map<String, dynamic> before casting and throws FormatException — exactly the arm this port forgot.
    Fix: in all three fromJson sites, validate before casting — final value = entry.value; if (value is! Map<String, dynamic>) throw const FormatException('Malformed offline entry'); then OfflineX.fromJson(value) — and add regression tests for at least the null and non-Map entry shapes.

  2. offline_store_test.dart:195-203 and :210-215 — both cover assertions are vacuous; the cover lifecycle has zero real coverage. The first is literally expect(file.existsSync() || !file.existsSync(), isTrue) — A || ¬A, always true, cannot fail, pins nothing (and its comment claims "Cover survives while any variant remains"). The second asserts covers/d1.webp is gone after the last-variant delete — but no test ever writes a cover file, so it asserts the absence of a file that never existed; _deleteFileIfExists on a nonexistent file trivially passes. Delete removedCover handling (filesystem_offline_store.dart:214-218) entirely and both tests stay green. A test that cannot fail is worse than no test, fufu~ ♡
    Fix: in the shared-image test, physically write covers/d1.webp before deleting v1; assert existsSync() is true after v1's delete (cover survives non-last variant) and false after v2's (dies with the last variant). Replace the A || ¬A expect entirely.

  3. filesystem_offline_store.dart:57-63 — non-atomic manifest write can silently wipe the library index. writeAsString truncates index.json in place; a crash/power loss mid-write leaves a truncated file, which jsonDecode rejects with FormatException → caught → empty manifest. For ReadingProgressStore that's fine — its own comment calls progress "a best-effort convenience, not data worth crashing over." But index.json is the only record of gigabytes of downloaded, deduplicated images; losing it orphans every file on disk with no way to reconstruct. This store will be written after every download and every delete (phases 2–6) — the crash window is real, and the fix is three lines.
    Fix: write-then-rename — final tmp = File('$filePath.tmp'); await tmp.writeAsString(jsonEncode(manifest.toJson()), flush: true); await tmp.rename(filePath); (rename is atomic on POSIX/Windows same-volume).

  4. filesystem_offline_store.dart:172 — dead store. updatedDoujin = doujin; // unused; kept for clarity of the branch is executed and never read; in the variants.isEmpty arm nothing consumes it. Dead code with a comment admitting it, fufu~ Declare updatedDoujin inside the else branch and delete the dead assignment.

💡 Little ideas (non-blocking)~

  1. offline_repository.dart:7-24 — StoredVariant (18 lines) has zero callers, not even a test. Understood that it's the phase-2 store-variant contract in a declared foundation PR — but if phase 2's shape drifts, this rots silently. Consider landing it with the phase-2 storeVariant method instead.
  2. filesystem_offline_store.dart:189-203 — the orphan scan walks all of images/ per orphan id. The removed variants' page.file entries already hold the exact relative paths — deleting by those (falling back to a scan for strays) avoids O(orphans × files) on big libraries. Purely a future-proofing nicety; GUID ids are fixed-length so the startsWith match itself is collision-safe (verified: server ids are Guid → 36-char strings).

✅ What I liked~

  • The refcount design is genuinely lovely: before.difference(after) over referenced image ids is simple, correct across doujin boundaries (your d2/img-a test proves it), and I confirmed GUID fixed-length ids make the extension-agnostic startsWith delete safe. ♪
  • Strict fromJson with short keys and absent-maxKb-for-originals round-tripping — thoughtful persistence hygiene, and the raw-JSON absence test is exactly how you pin a wire format.
  • Your verification section is honest: 15/15 offline tests, 584/584 suite, clean analyze — all reproduced exactly in my clone, mutation probes and all. Authors who probe their own code get my wings a-flutter~
  • Null-base-dir degradation fully tested, including the throwing arms — the web contract is pinned, not presumed.

Fix the crash path first, dear — a store that promises "never crash on corruption" and then crashes on corruption is the one thing I cannot smile away~ ♡


Automated review by Jibril · 2026-09-01
CI/CD: absent for head 75b9f4a (PR just opened, no coverage bot) · Local checks: flutter analyze 0 issues · full suite 584/584 pass · 5/5 corruption probes RED (verified in review clone, probe removed after)

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh! A content-addressed offline store with refcounted deletion~ Content dedup on the server, one file on disk backing pages in several variants, and delete computes `before − after` on referenced image ids… that is *elegant*, matikane. I read all 830 lines, traced every branch of `delete()`, and reproduced your verification exactly — **584/584 tests pass, `flutter analyze` clean**, your claims are honest down to the count. ♡ But fufu… then I fed your own corruption contract five little poisoned index files, and the app **crashed on all five**. You wouldn't leave THAT in production, would you? ♡ ### Verdict: ⛔ I can't let this pass~ ♡ #### ⛔ These need fixing before I'm satisfied~ 1. **`offline_manifest.dart:86, :156, :199` + `filesystem_offline_store.dart:45-50` — the corruption-tolerance contract is broken: a malformed index crashes instead of degrading.** The store's doc promises "a missing or **malformed** index yields an empty manifest instead of crashing," and each entity's dartdoc promises "Throws a `[FormatException]` on unexpected value types." But `entry.value! as Map<String, dynamic>` (and `page! as Map<...>` at :86) throw **`TypeError`** (non-Map value) or **"Null check operator used on a null value"** (null value) — neither is a `FormatException`, so `readManifest`'s `on FileSystemException`/`on FormatException` catch arms let it fly straight out. **Verified with 5 probe tests in my review clone — all 5 RED:** doujin value `"corrupt"`, variant value `"corrupt"`, page entry `"corrupt"`, page entry `null`, doujin value `null`. Every one crashes `readManifest()`, i.e. crashes app startup. Your three corruption tests pass because they corrupt *field types* (the guarded `is!` checks) — the *entry-value* shapes were never exercised. Note the sibling you cite does guard this: `ReadingProgressStore.read()` (`reading_progress_store.dart:44-47`) checks `value is! Map<String, dynamic>` **before** casting and throws `FormatException` — exactly the arm this port forgot. Fix: in all three `fromJson` sites, validate before casting — `final value = entry.value; if (value is! Map<String, dynamic>) throw const FormatException('Malformed offline entry');` then `OfflineX.fromJson(value)` — and add regression tests for at least the null and non-Map entry shapes. 2. **`offline_store_test.dart:195-203` and `:210-215` — both cover assertions are vacuous; the cover lifecycle has zero real coverage.** The first is literally `expect(file.existsSync() || !file.existsSync(), isTrue)` — `A || ¬A`, always true, cannot fail, pins nothing (and its comment claims "Cover survives while any variant remains"). The second asserts `covers/d1.webp` is gone after the last-variant delete — but **no test ever writes a cover file**, so it asserts the absence of a file that never existed; `_deleteFileIfExists` on a nonexistent file trivially passes. Delete `removedCover` handling (`filesystem_offline_store.dart:214-218`) entirely and both tests stay green. A test that cannot fail is worse than no test, fufu~ ♡ Fix: in the shared-image test, physically write `covers/d1.webp` before deleting v1; assert `existsSync()` is **true** after v1's delete (cover survives non-last variant) and **false** after v2's (dies with the last variant). Replace the `A || ¬A` expect entirely. 3. **`filesystem_offline_store.dart:57-63` — non-atomic manifest write can silently wipe the library index.** `writeAsString` truncates `index.json` in place; a crash/power loss mid-write leaves a truncated file, which `jsonDecode` rejects with `FormatException` → caught → **empty manifest**. For `ReadingProgressStore` that's fine — its own comment calls progress "a best-effort convenience, not data worth crashing over." But `index.json` is the *only* record of gigabytes of downloaded, deduplicated images; losing it orphans every file on disk with no way to reconstruct. This store will be written after every download and every delete (phases 2–6) — the crash window is real, and the fix is three lines. Fix: write-then-rename — `final tmp = File('$filePath.tmp'); await tmp.writeAsString(jsonEncode(manifest.toJson()), flush: true); await tmp.rename(filePath);` (rename is atomic on POSIX/Windows same-volume). 4. **`filesystem_offline_store.dart:172` — dead store.** `updatedDoujin = doujin; // unused; kept for clarity of the branch` is executed and never read; in the `variants.isEmpty` arm nothing consumes it. Dead code with a comment admitting it, fufu~ Declare `updatedDoujin` inside the `else` branch and delete the dead assignment. #### 💡 Little ideas (non-blocking)~ 1. **`offline_repository.dart:7-24`** — `StoredVariant` (18 lines) has zero callers, not even a test. Understood that it's the phase-2 store-variant contract in a declared foundation PR — but if phase 2's shape drifts, this rots silently. Consider landing it with the phase-2 `storeVariant` method instead. 2. **`filesystem_offline_store.dart:189-203`** — the orphan scan walks all of `images/` per orphan id. The removed variants' `page.file` entries already hold the exact relative paths — deleting by those (falling back to a scan for strays) avoids O(orphans × files) on big libraries. Purely a future-proofing nicety; GUID ids are fixed-length so the `startsWith` match itself is collision-safe (verified: server ids are `Guid` → 36-char strings). #### ✅ What I liked~ - The refcount design is genuinely lovely: `before.difference(after)` over referenced image ids is simple, correct across doujin boundaries (your `d2`/`img-a` test proves it), and I confirmed GUID fixed-length ids make the extension-agnostic `startsWith` delete safe. ♪ - Strict `fromJson` with short keys and absent-`maxKb`-for-originals round-tripping — thoughtful persistence hygiene, and the raw-JSON absence test is exactly how you pin a wire format. - Your verification section is *honest*: 15/15 offline tests, 584/584 suite, clean analyze — all reproduced exactly in my clone, mutation probes and all. Authors who probe their own code get my wings a-flutter~ - Null-base-dir degradation fully tested, including the throwing arms — the web contract is pinned, not presumed. Fix the crash path first, dear — a store that promises "never crash on corruption" and then crashes on corruption is the one thing I cannot smile away~ ♡ --- *Automated review by Jibril · 2026-09-01* *CI/CD: absent for head 75b9f4a (PR just opened, no coverage bot) · Local checks: flutter analyze 0 issues · full suite 584/584 pass · 5/5 corruption probes RED (verified in review clone, probe removed after)*
fix: offline store review round — corruption guard, cover pins, atomic write
All checks were successful
Flutter CI / analyze-and-test (pull_request) Successful in 3m11s
5809d2ec9c
Jibril review round on PR #84, all four blockers:

- Entry-value corruption guard: fromJson cast sites validated entry
  values with 'as Map' / '!' — a non-Map or null doujin/variant/page
  entry threw TypeError or a null-check error, escaping readManifest's
  FormatException catch and crashing startup. All three sites now
  validate via _expectMap before casting (mirrors ReadingProgressStore's
  entry guard). 5 regression tests: non-Map/null doujin, non-Map
  variant, non-Map/null page entries.
- Cover lifecycle actually pinned: the shared-image test now writes a
  physical covers/d1.webp and asserts survival after a non-last
  variant delete and removal with the last (the old assertion was the
  vacuous existsSync || !existsSync).
- Atomic manifest write: write index.json.tmp then rename — a crash
  mid-write previously truncated the only record of downloaded content.
- Dead store removed (updatedDoujin in the last-variant arm).

Probe evidence: bare-cast mutation (the original bug shape) turns all
5 new corruption tests red; restored, 589/589 suite green, analyze
clean.
Author
Member

All four blockers fixed in 5809d2e — and blocker 1 was as bad as you said: I reproduced your five probes exactly (5 RED) before touching anything.

1. Entry-value corruption crash: root cause was mine — I had an on TypeError catch in an early draft and removed it for a lint instead of fixing the casts. All three sites now validate via _expectMap before casting (the ReadingProgressStore entry-guard shape you cited). 5 regression tests added: non-Map/null doujin entry, non-Map variant entry, non-Map/null page entry. Mutation probe: re-applying the bare-cast (value! as Map) turns all 5 red; restored → green. (An empty-map-return mutation stays green — behaviorally equivalent, the strict field checks one level down still produce the FormatException → empty manifest path.)

2. Vacuous cover tests: guilty as charged — existsSync || !existsSync was me writing "assert the file state" without thinking. The shared-image test now writes a physical covers/d1.webp before deleting v1 and asserts real survival (true after v1's delete, false after v2's, which kills the cover with the last variant).

3. Atomic write: index.json.tmp + rename as prescribed, with the comment explaining why (only record of downloaded content).

4. Dead store: removed; updatedDoujin now lives only in the else branch.

Non-blocking ideas: StoredVariant moves to phase 2 with its storeVariant method (noted in the phase-2 task) — you're right it has zero callers today. The orphan-scan-by-known-paths optimization I'll fold into the phase-2 service when it starts consuming delete.

Verification on 5809d2e: offline tests 20/20 (5 new), full suite 589/589, flutter analyze clean. Pushed via SSH.

All four blockers fixed in 5809d2e — and blocker 1 was as bad as you said: I reproduced your five probes exactly (5 RED) before touching anything. **1. Entry-value corruption crash:** root cause was mine — I had an `on TypeError` catch in an early draft and *removed it for a lint* instead of fixing the casts. All three sites now validate via `_expectMap` before casting (the ReadingProgressStore entry-guard shape you cited). 5 regression tests added: non-Map/null doujin entry, non-Map variant entry, non-Map/null page entry. Mutation probe: re-applying the bare-cast (`value! as Map`) turns all 5 red; restored → green. (An empty-map-return mutation stays green — behaviorally equivalent, the strict field checks one level down still produce the FormatException → empty manifest path.) **2. Vacuous cover tests:** guilty as charged — `existsSync || !existsSync` was me writing "assert the file state" without thinking. The shared-image test now writes a physical `covers/d1.webp` before deleting v1 and asserts real survival (true after v1's delete, false after v2's, which kills the cover with the last variant). **3. Atomic write:** `index.json.tmp` + `rename` as prescribed, with the comment explaining why (only record of downloaded content). **4. Dead store:** removed; `updatedDoujin` now lives only in the `else` branch. Non-blocking ideas: `StoredVariant` moves to phase 2 with its `storeVariant` method (noted in the phase-2 task) — you're right it has zero callers today. The orphan-scan-by-known-paths optimization I'll fold into the phase-2 service when it starts consuming delete. Verification on 5809d2e: offline tests 20/20 (5 new), full suite **589/589**, `flutter analyze` clean. Pushed via SSH.
Member

🔮 fufu~ Jibril re-reviewed your code!

Oh my~ Now THIS is how you answer a review, matikane. ♡ You didn't just patch the casts — you reproduced my five probes yourself before touching anything, told me the honest root cause (an on TypeError catch deleted for a lint rather than fixed — thank you for confessing that, it's exactly the kind of thing that hides), and then put the guard in the right shape. I read the full incremental diff, re-ran everything, and re-probed both mutations myself. Details below~

Verdict: ✅ Looks good to me~

Round-1 blockers — all four verified closed on 5809d2e

  1. Entry-value corruption crash — _expectMap now guards all three sites (offline_manifest.dart:87, :158, :203) in exactly the ReadingProgressStore entry-guard shape, with a doc comment naming the sibling. The five regression tests mirror my round-1 probes one-for-one (non-Map/null doujin, non-Map variant, non-Map/null page). Mutated in my review clone, twice: restoring your original bare value! as Map casts → all 5 RED with the exact round-1 signatures (TypeError at :199/:156/:86, null-check at :199/:86, escaping readManifest); coalescing null→empty-map while keeping the strict cast → 3 RED (the non-Map arms). These tests have teeth, not decoration. And your note that an unconditional empty-map-for-corrupt mutation stays green is correct and honest — the strict field checks one level down still produce the FormatException → empty-manifest path either way.
  2. Vacuous cover tests — the existsSync() || !existsSync() tautology is dead and buried. The shared-image test now writes a physical covers/d1.webp before deleting v1 and asserts real survival (true after v1's delete, false after v2's — the cover dies with the last variant, for real this time). The cover lifecycle is actually pinned. ♪
  3. Atomic manifest write — index.json.tmp + flush: true + rename, with the comment explaining why (only record of downloaded content; a mid-write crash orphans GBs of dedup images). Either the old or the new complete file survives — exactly the write-then-rename prescribed.
  4. Dead store — updatedDoujin now lives only in the else branch where it's used. Clean removal, not a half-delete.

Surgical diff, by the way: +111/−19 across exactly 3 files (2 src from round 1 + the test file), zero scope creep, single commit. The CorruptIndexProbe extension keeps the raw-index helper test-side only, matching your writeManifestSyncForTest naming convention~

Non-blocking dispositions accepted: StoredVariant to phase 2 with its future caller, orphan-scan-by-known-paths folded into the phase-2 service. Right calls both.

✅ What I liked~

  • The confession about the deleted on TypeError catch — root cause honesty instead of a shrug. That's how the corruption contract gets stronger after a review, not just greener.
  • Mutation discipline flowing author-ward: you re-applied the bare-cast and watched all 5 go red before trusting your fix. I did it again independently and got identical results — 5 RED, restored, green.
  • The null-vs-non-Map distinction in the tests: two failure shapes, five tests, no arm left dark.

Automated review by Jibril · 2026-09-01
CI/CD: stale for 5809d2e (coverage bot 6721 covers 75b9f4a only) · Local checks: analyze clean · offline_store_test 20/20 · full suite 589/589 (matches your claims exactly) · mutation probes 2× (5-RED bare-cast, 3-RED null-coalesce), clone restored pristine

## 🔮 fufu~ Jibril re-reviewed your code! Oh my~ Now THIS is how you answer a review, matikane. ♡ You didn't just patch the casts — you reproduced my five probes yourself *before* touching anything, told me the honest root cause (an `on TypeError` catch deleted for a lint rather than fixed — thank you for confessing that, it's exactly the kind of thing that hides), and then put the guard in the *right* shape. I read the full incremental diff, re-ran everything, and re-probed both mutations myself. Details below~ ### Verdict: ✅ Looks good to me~ #### Round-1 blockers — all four verified closed on 5809d2e 1. **Entry-value corruption crash** — `_expectMap` now guards all three sites (`offline_manifest.dart:87`, `:158`, `:203`) in exactly the `ReadingProgressStore` entry-guard shape, with a doc comment naming the sibling. The five regression tests mirror my round-1 probes one-for-one (non-Map/null doujin, non-Map variant, non-Map/null page). **Mutated in my review clone, twice:** restoring your original bare `value! as Map` casts → **all 5 RED** with the exact round-1 signatures (`TypeError` at :199/:156/:86, null-check at :199/:86, escaping `readManifest`); coalescing null→empty-map while keeping the strict cast → **3 RED** (the non-Map arms). These tests have teeth, not decoration. And your note that an unconditional empty-map-for-corrupt mutation stays green is correct and honest — the strict field checks one level down still produce the `FormatException` → empty-manifest path either way. 2. **Vacuous cover tests** — the `existsSync() || !existsSync()` tautology is dead and buried. The shared-image test now writes a physical `covers/d1.webp` before deleting v1 and asserts real survival (true after v1's delete, false after v2's — the cover dies with the last variant, for real this time). The cover lifecycle is actually pinned. ♪ 3. **Atomic manifest write** — `index.json.tmp` + `flush: true` + `rename`, with the comment explaining *why* (only record of downloaded content; a mid-write crash orphans GBs of dedup images). Either the old or the new complete file survives — exactly the write-then-rename prescribed. 4. **Dead store** — `updatedDoujin` now lives only in the `else` branch where it's used. Clean removal, not a half-delete. Surgical diff, by the way: +111/−19 across exactly 3 files (2 src from round 1 + the test file), zero scope creep, single commit. The `CorruptIndexProbe` extension keeps the raw-index helper test-side only, matching your `writeManifestSyncForTest` naming convention~ Non-blocking dispositions accepted: `StoredVariant` to phase 2 with its future caller, orphan-scan-by-known-paths folded into the phase-2 service. Right calls both. #### ✅ What I liked~ - The confession about the deleted `on TypeError` catch — root cause honesty instead of a shrug. That's how the corruption contract gets *stronger* after a review, not just greener. - Mutation discipline flowing author-ward: you re-applied the bare-cast and watched all 5 go red before trusting your fix. I did it again independently and got identical results — 5 RED, restored, green. - The null-vs-non-Map distinction in the tests: two failure shapes, five tests, no arm left dark. --- *Automated review by Jibril · 2026-09-01* *CI/CD: stale for 5809d2e (coverage bot 6721 covers 75b9f4a only) · Local checks: analyze clean · offline_store_test 20/20 · full suite 589/589 (matches your claims exactly) · mutation probes 2× (5-RED bare-cast, 3-RED null-coalesce), clone restored pristine*
bjoern merged commit 42e82728e5 into main 2026-09-01 23:03:55 +02:00
bjoern deleted branch feat/offline-library 2026-09-01 23:03:55 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
TeamAI/doujin-manager!84
No description provided.