From 12cd18bade5b474ec9cc68d5db5b931bb2e84056 Mon Sep 17 00:00:00 2001 From: Matt Ramotar Date: Sun, 16 Aug 2026 16:05:15 -0500 Subject: [PATCH 1/2] Merge Store 6 main onto store6 --- .claude-plugin/marketplace.json | 16 + .claude/skills/code-documentation/SKILL.md | 76 + .../references/evidence-and-verification.md | 102 + .../references/languages/go.md | 71 + .../references/languages/java.md | 70 + .../references/languages/kotlin.md | 68 + .../references/languages/python.md | 74 + .../references/languages/rust.md | 75 + .../languages/typescript-javascript.md | 77 + .../surfaces/implementation-documentation.md | 40 + .../surfaces/inline-documentation.md | 54 + .../surfaces/interface-documentation.md | 41 + .../references/surfaces/package-readme.md | 42 + .../references/surfaces/repository-readme.md | 45 + .../skills/documentation-discipline/SKILL.md | 50 + .../references/discipline-rules.md | 119 + .editorconfig | 4 - .github/docs-sync-sources.txt | 11 + .github/workflows/KMMBridge-Release.yml | 12 - .github/workflows/ci.yml | 62 +- .github/workflows/create_swift_package.yml | 67 - .github/workflows/store6-benchmarks.yml | 92 + .github/workflows/store6-full-jvm.yml | 71 + .github/workflows/store6.yml | 801 +++ AGENTS.md | 66 + README.md | 28 + ROADMAP.md | 113 + STABILITY.md | 209 + build.gradle.kts | 113 +- cache/README.md | 58 - cache/api/jvm/cache.api | 69 - cache/build.gradle.kts | 22 - cache/config/ktlint/baseline.xml | 24 - cache/gradle.properties | 3 - .../store/cache5/Cache.kt | 69 - .../store/cache5/CacheBuilder.kt | 79 - .../store/cache5/LocalCache.kt | 2082 ------- .../store/cache5/MonotonicTicker.kt | 7 - .../store/cache5/RemovalCause.kt | 15 - .../store/cache5/StoreMultiCache.kt | 171 - .../store/cache5/StoreMultiCacheAccessor.kt | 182 - .../store/cache5/Ticker.kt | 6 - .../store/cache5/Weigher.kt | 6 - .../store/cache5/CacheTests.kt | 108 - core/api/jvm/core.api | 69 - core/build.gradle.kts | 14 - core/gradle.properties | 3 - .../store/core5/ExperimentalStoreApi.kt | 10 - .../store/core5/InsertionStrategy.kt | 8 - .../store/core5/KeyProvider.kt | 14 - .../store/core5/StoreData.kt | 36 - .../store/core5/StoreKey.kt | 61 - docs/store6/important-defaults.md | 130 + docs/store6/invalidate-vs-clear.md | 160 + docs/store6/key-design.md | 153 + docs/store6/quickstart.md | 193 + ...08-06-store6-alpha01-source-doc-cleanup.md | 376 ++ ...2026-08-06-store6-doc-cleanup-inventory.md | 971 ++++ ...re38-building-a-store6-data-layer-skill.md | 398 ++ gradle.properties | 11 +- gradle/gradle-daemon-jvm.properties | 13 - gradle/libs.versions.toml | 125 +- gradle/wrapper/gradle-wrapper.jar | Bin 48462 -> 43453 bytes gradle/wrapper/gradle-wrapper.properties | 6 +- gradlew | 19 +- gradlew.bat | 38 +- llms.txt | 65 + multicast/api/jvm/multicast.api | 8 - multicast/build.gradle.kts | 24 - multicast/gradle.properties | 3 - .../store/multicast5/Actor.kt | 34 - .../store/multicast5/ChannelManager.kt | 433 -- .../store/multicast5/Multicaster.kt | 136 - .../store/multicast5/SharedFlowProducer.kt | 92 - .../store/multicast5/StoreRealActor.kt | 90 - .../multicast5/StoreChannelManagerTests.kt | 124 - plugins/store/.claude-plugin/plugin.json | 24 + plugins/store/README.md | 12 + .../fixtures/AtlasApi.kt | 9 + .../fixtures/Db.kt | 26 + .../fixtures/ProfileViewModel.kt | 27 + .../fixtures/REQUIREMENTS.md | 14 + .../building-a-store6-data-layer/results.md | 37 + .../building-a-store6-data-layer/scenario.md | 34 + .../fixtures/UserRepository.kt | 92 + .../evals/migrating-to-store6/results.md | 28 + .../evals/migrating-to-store6/scenario.md | 28 + .../building-a-store6-data-layer/SKILL.md | 63 + .../references/compose.md | 81 + .../references/room.md | 145 + .../references/sqldelight.md | 54 + .../references/swift.md | 68 + .../store/skills/migrating-to-store6/SKILL.md | 63 + .../references/component-map.md | 51 + .../references/from-store4.md | 25 + .../references/mutations.md | 46 + .../references/store5-to-store6.md | 216 + renovate.json | 14 + rx2/api/rx2.api | 26 - rx2/build.gradle.kts | 23 - rx2/gradle.properties | 3 - .../store/rx2/RxFetcher.kt | 68 - .../store/rx2/RxSourceOfTruth.kt | 63 - .../store/rx2/RxStore.kt | 46 - .../store/rx2/RxStoreBuilder.kt | 24 - .../store/rx2/test/FlowTestExt.kt | 88 - .../store/rx2/test/HotRxSingleStoreTest.kt | 83 - .../store/rx2/test/RxFlowableStoreTest.kt | 118 - .../rx2/test/RxSingleStoreExtensionsTest.kt | 80 - .../store/rx2/test/RxSingleStoreTest.kt | 131 - settings.gradle | 53 +- store/api/jvm/store.api | 697 --- store/build.gradle.kts | 47 - store/config/ktlint/baseline.xml | 24 - store/gradle.properties | 3 - .../store5/impl/extensions/Clock.android.kt | 3 - .../store/store5/Bookkeeper.kt | 32 - .../store/store5/Clear.kt | 24 - .../store/store5/Converter.kt | 42 - .../store/store5/Fetcher.kt | 200 - .../store/store5/FetcherResult.kt | 13 - .../store/store5/Logger.kt | 24 - .../store/store5/MemoryPolicy.kt | 116 - .../store/store5/MutableStore.kt | 11 - .../store/store5/MutableStoreBuilder.kt | 53 - .../store/store5/OnFetcherCompletion.kt | 6 - .../store/store5/OnUpdaterCompletion.kt | 6 - .../store/store5/Read.kt | 17 - .../store/store5/SourceOfTruth.kt | 203 - .../store/store5/Store.kt | 37 - .../store/store5/StoreBuilder.kt | 114 - .../store/store5/StoreDefaults.kt | 26 - .../store/store5/StoreReadRequest.kt | 111 - .../store/store5/StoreReadResponse.kt | 180 - .../store/store5/StoreWriteRequest.kt | 21 - .../store/store5/StoreWriteResponse.kt | 15 - .../store/store5/Updater.kt | 43 - .../store/store5/UpdaterResult.kt | 15 - .../store/store5/Validator.kt | 20 - .../store/store5/Write.kt | 14 - .../store/store5/impl/DefaultLogger.kt | 26 - .../store/store5/impl/FetcherController.kt | 175 - .../store5/impl/OnStoreWriteCompletion.kt | 8 - .../store/store5/impl/RealBookkeeper.kt | 22 - .../store/store5/impl/RealMutableStore.kt | 347 -- .../store5/impl/RealMutableStoreBuilder.kt | 109 - .../store/store5/impl/RealSourceOfTruth.kt | 70 - .../store/store5/impl/RealStore.kt | 376 -- .../store/store5/impl/RealStoreBuilder.kt | 145 - .../store5/impl/RealStoreWriteRequest.kt | 10 - .../store/store5/impl/RealValidator.kt | 9 - .../store/store5/impl/RefCountedResource.kt | 65 - .../store5/impl/SourceOfTruthWithBarrier.kt | 226 - .../store/store5/impl/extensions/Clock.kt | 9 - .../store/store5/impl/extensions/store.kt | 156 - .../store/store5/impl/operators/FlowMerge.kt | 46 - .../store/store5/impl/operators/MapIndexed.kt | 27 - .../internal/concurrent/ThreadSafety.kt | 12 - .../store5/internal/definition/Timestamp.kt | 3 - .../internal/definition/WriteRequestQueue.kt | 5 - .../result/EagerConflictResolutionResult.kt | 17 - .../result/StoreDelegateWriteResult.kt | 11 - .../store/store5/storeBuilder.uml | 19 - .../store/store5/ClearAllStoreTests.kt | 180 - .../store/store5/ClearStoreByKeyTests.kt | 159 - .../store/store5/FallbackTests.kt | 149 - .../store/store5/FetcherControllerTests.kt | 141 - .../store/store5/FetcherResponseTests.kt | 300 - .../store/store5/FlowStoreTests.kt | 1341 ----- .../store/store5/HotFlowStoreTests.kt | 102 - .../store/store5/KeyTrackerTests.kt | 136 - .../store/store5/LocalOnlyTests.kt | 168 - .../store/store5/MapIndexedTests.kt | 23 - .../store/store5/SourceOfTruthErrorsTests.kt | 453 -- .../store5/SourceOfTruthWithBarrierTests.kt | 285 - .../store/store5/StoreReadResponseTests.kt | 52 - .../store5/StoreWithInMemoryCacheTests.kt | 133 - .../store5/StreamWithoutSourceOfTruthTests.kt | 129 - .../store/store5/UpdaterTests.kt | 292 - .../store/store5/ValueFetcherTests.kt | 69 - .../MutableStoreConcurrencyTest.kt | 126 - .../mutablestore/RealMutableStoreTest.kt | 485 -- .../store5/mutablestore/util/TestCache.kt | 76 - .../store5/mutablestore/util/TestConverter.kt | 34 - .../store5/mutablestore/util/TestFetcher.kt | 25 - .../util/TestInMemoryBookkeeper.kt | 28 - .../store5/mutablestore/util/TestLogger.kt | 19 - .../mutablestore/util/TestSourceOfTruth.kt | 67 - .../store5/mutablestore/util/TestStore.kt | 29 - .../store5/mutablestore/util/TestUpdater.kt | 25 - .../store5/mutablestore/util/TestValidator.kt | 18 - .../store/store5/util/AsFlowable.kt | 132 - .../store/store5/util/FakeFetcher.kt | 61 - .../store/store5/util/InMemoryPersister.kt | 49 - .../store/store5/util/TestApi.kt | 15 - .../store/store5/util/TestStoreExt.kt | 23 - .../store/store5/util/fake/NoteCollections.kt | 11 - .../store/store5/util/fake/Notes.kt | 16 - .../store/store5/util/fake/NotesApi.kt | 60 - .../store5/util/fake/NotesBookkeeping.kt | 47 - .../util/fake/NotesConverterProvider.kt | 20 - .../store/store5/util/fake/NotesDatabase.kt | 51 - .../store/store5/util/fake/NotesKey.kt | 7 - .../store5/util/fake/NotesUpdaterProvider.kt | 21 - .../store/store5/util/fake/NotesValidator.kt | 13 - .../util/fake/fallback/HardcodedPages.kt | 18 - .../store/store5/util/fake/fallback/Page.kt | 10 - .../util/fake/fallback/PagesDatabase.kt | 15 - .../util/fake/fallback/PrimaryPagesApi.kt | 29 - .../util/fake/fallback/SecondaryPagesApi.kt | 18 - .../store/store5/util/model/NoteData.kt | 35 - .../store/store5/impl/extensions/Clock.ios.kt | 16 - .../store/store5/impl/extensions/Clock.js.kt | 7 - .../store/store5/impl/extensions/Clock.jvm.kt | 3 - .../store5/impl/extensions/Clock.linuxX64.kt | 16 - .../store5/impl/extensions/Clock.wasmJs.kt | 6 - store6-benchmarks/README.md | 116 + store6-benchmarks/build.gradle.kts | 61 + .../store6/benchmarks/BenchKey.kt | 12 + .../store6/benchmarks/ColdStartBenchmark.kt | 51 + .../store6/benchmarks/GetPathBenchmark.kt | 56 + .../store6/benchmarks/NoopTelemetry.kt | 14 + .../benchmarks/StreamEmissionBenchmark.kt | 149 + .../benchmarks/SubscriptionChurnBenchmark.kt | 54 + .../benchmarks/TelemetryOverheadBenchmark.kt | 123 + .../store6/benchmarks/HarnessSmokeTest.kt | 95 + .../benchmarks/TelemetryAllocationProbe.kt | 173 + store6-compose-demo/build.gradle.kts | 44 + .../store6/composedemo/DemoScreen.kt | 106 + .../store6/composedemo/DemoStore.kt | 53 + .../store6/composedemo/DemoUiState.kt | 44 + .../store6/composedemo/Main.kt | 19 + .../store6/composedemo/StabilityProbe.kt | 48 + .../store6/composedemo/DemoFetcherTest.kt | 36 + .../store6/composedemo/DemoUiStateTest.kt | 78 + store6-compose/README.md | 61 + store6-compose/api/android/store6-compose.api | 17 + store6-compose/api/jvm/store6-compose.api | 17 + store6-compose/api/store6-compose.klib.api | 14 + store6-compose/build.gradle.kts | 54 + store6-compose/gradle.properties | 3 + .../src/androidMain}/AndroidManifest.xml | 0 .../store6/compose/CollectAsState.kt | 79 + .../compose/CollectAsStateWithLifecycle.kt | 71 + .../store6/compose/StoreResultEquivalence.kt | 65 + .../store6/compose/ClosedStoreBehaviorTest.kt | 68 + .../store6/compose/CollectAsStateTest.kt | 167 + .../CollectAsStateWithLifecycleTest.kt | 123 + .../store6/compose/ComposeTestHarness.kt | 68 + .../store6/compose/SkipEqualDataTest.kt | 74 + .../compose/StoreResultMutationPolicyTest.kt | 59 + .../compose/docs/PendingWriteUiDocsSnippet.kt | 22 + .../stability/store6-stability.conf | 9 + store6-core/api/android/store6-core.api | 344 ++ store6-core/api/jvm/store6-core.api | 344 ++ store6-core/api/store6-core.klib.api | 380 ++ store6-core/api/swift/objc/Store6Core.h | 1005 ++++ store6-core/api/swift/skie/Store6CoreSkie.h | 1358 +++++ .../api/swift/skie/Store6CoreSkie.swift | 2895 +++++++++ store6-core/build.gradle.kts | 37 + store6-core/dokka/Module.md | 17 + store6-core/gradle.properties | 3 + .../src/androidMain/AndroidManifest.xml | 3 +- .../store6/core/internal/WallClock.android.kt | 4 + .../store6/core/internal/WallClock.apple.kt | 8 + .../store6/core/Annotations.kt | 64 + .../store6/core/Freshness.kt | 52 + .../store6/core/Origin.kt | 16 + .../store6/core/Store.kt | 174 + .../store6/core/StoreBuilder.kt | 192 + .../store6/core/StoreError.kt | 61 + .../store6/core/StoreException.kt | 22 + .../store6/core/StoreKey.kt | 25 + .../store6/core/StoreMeta.kt | 16 + .../store6/core/StoreResult.kt | 71 + .../store6/core/internal/Bookkeeper.kt | 218 + .../store6/core/internal/EngineResidency.kt | 24 + .../store6/core/internal/FetchSlot.kt | 139 + .../store6/core/internal/FetcherAdapters.kt | 29 + .../core/internal/FreshnessValidator.kt | 95 + .../core/internal/InMemorySourceOfTruth.kt | 90 + .../store6/core/internal/KeyEngine.kt | 5166 +++++++++++++++++ .../store6/core/internal/KeyId.kt | 23 + .../store6/core/internal/KeyRegistry.kt | 192 + .../store6/core/internal/KeyState.kt | 49 + .../core/internal/MaintenanceCoordinator.kt | 171 + .../store6/core/internal/Projection.kt | 25 + .../store6/core/internal/ReaderRecord.kt | 108 + .../store6/core/internal/RealStore.kt | 218 + .../store6/core/internal/RealStoreRuntime.kt | 38 + .../store6/core/internal/StoreLifecycle.kt | 26 + .../store6/core/internal/StoreResultFlows.kt | 104 + .../store6/core/internal/Transitions.kt | 299 + .../store6/core/internal/ValueEnvelope.kt | 24 + .../store6/core/internal/WallClock.kt | 15 + .../store6/core/seam/Bookkeeper.kt | 119 + .../store6/core/seam/Fetcher.kt | 28 + .../store6/core/seam/FetcherResult.kt | 45 + .../store6/core/seam/FreshnessValidator.kt | 63 + .../store6/core/seam/KeyEvents.kt | 46 + .../store6/core/seam/Overlay.kt | 52 + .../store6/core/seam/SourceOfTruth.kt | 76 + .../store6/core/seam/StoreResults.kt | 28 + .../store6/core/seam/StoreRuntime.kt | 39 + .../store6/core/seam/StoreTelemetry.kt | 55 + .../store6/core/seam/StoreWriteHandle.kt | 55 + .../core/seam/TransactionalSourceOfTruth.kt | 22 + .../store6/core/seam/WallClock.kt | 20 + .../core/EmissionSequenceConformanceTest.kt | 333 ++ .../store6/core/FetcherContractTest.kt | 268 + .../core/FreshnessPolicyConformanceTest.kt | 504 ++ .../core/KeyEngineSourceOfTruthRaceTest.kt | 656 +++ .../store6/core/MaintenanceTestFakes.kt | 133 + .../store6/core/NamespacedTestKey.kt | 10 + .../store6/core/OverlayConformanceTest.kt | 232 + .../store6/core/PublicSurfaceTest.kt | 85 + .../core/ReaderGenerationRecoveryTest.kt | 142 + .../store6/core/ReaderHopSourceOfTruth.kt | 51 + .../store6/core/SchedulerPerturbationRuns.kt | 29 + .../store6/core/SeamFetcherTest.kt | 40 + .../core/SingleFlightConformanceTest.kt | 108 + .../core/SourceOfTruthAdditionalRaceTest.kt | 510 ++ .../SourceOfTruthBindingConformanceTest.kt | 2085 +++++++ ...ourceOfTruthCancellationConformanceTest.kt | 545 ++ .../core/SourceOfTruthConformanceTest.kt | 497 ++ .../SourceOfTruthFailureConformanceTest.kt | 569 ++ .../core/SourceOfTruthHydrationRaceTest.kt | 245 + .../core/SourceOfTruthSubstitutionTest.kt | 188 + .../core/StoreBackpressureConformanceTest.kt | 170 + .../core/StoreBuilderPersistenceTest.kt | 48 + .../store6/core/StoreCloseLifecycleTest.kt | 119 + .../store6/core/StoreConfigSeamsTest.kt | 122 + .../core/StoreConformanceSubstitutionRuns.kt | 60 + .../store6/core/StoreConformanceTest.kt | 277 + .../store6/core/StoreDefaultsPinTest.kt | 144 + .../StoreDurableMaintenanceConformanceTest.kt | 147 + .../StoreDurableMaintenanceFailureTest.kt | 263 + .../core/StoreEvictionConformanceTest.kt | 387 ++ .../store6/core/StoreEvictionStressTest.kt | 114 + .../core/StoreInvalidationConformanceTest.kt | 851 +++ .../core/StoreInvalidationStressTest.kt | 107 + .../store6/core/StoreResultsTest.kt | 115 + .../core/StoreRevalidationConformanceTest.kt | 238 + .../store6/core/StoreRuntimeTest.kt | 164 + .../core/StoreScopedMaintenanceRaceTest.kt | 240 + .../store6/core/StoreTelemetryTest.kt | 116 + .../core/StoreZeroConfigEquivalenceTest.kt | 209 + .../store6/core/TestBarriers.kt | 23 + .../store6/core/TestClocks.kt | 41 + .../store6/core/TestKey.kt | 34 + .../core/docs/GuideSnippetCompilation.kt | 60 + .../BookkeeperOrderingConformanceTest.kt | 543 ++ .../core/internal/ConflateLatestDataTest.kt | 365 ++ .../core/internal/FreshnessValidatorTest.kt | 334 ++ .../internal/InMemoryBookkeeperKitTest.kt | 11 + .../core/internal/InMemoryBookkeeperTest.kt | 402 ++ .../internal/InMemorySourceOfTruthKitTest.kt | 108 + .../internal/InMemorySourceOfTruthTest.kt | 50 + .../core/internal/KeyEnginePlanningTest.kt | 5052 ++++++++++++++++ .../store6/core/internal/KeyRegistryTest.kt | 393 ++ .../internal/MaintenanceCoordinatorTest.kt | 321 + .../internal/OverlayProjectionProtocolTest.kt | 1951 +++++++ .../ProjectionAuthorizationHandoffTest.kt | 1029 ++++ .../ProjectionRecomputeVisibilityProbeTest.kt | 306 + .../internal/ReaderRecordResolutionTest.kt | 137 + .../internal/RotatingSlotSourceOfTruth.kt | 118 + .../core/internal/SharedFlowSourceOfTruth.kt | 89 + .../store6/core/internal/TransitionTest.kt | 474 ++ .../store6/core/internal/WallClock.js.kt | 6 + .../store6/core/internal/WallClock.jvm.kt | 4 + .../store6/core/FetcherDispatcherTest.kt | 27 + .../store6/core/internal/WallClock.linux.kt | 18 + .../store6/core/internal/WallClock.mingw.kt | 21 + .../store6/core/internal/WallClock.wasmJs.kt | 6 + store6-devtools-demo/README.md | 75 + store6-devtools-demo/build.gradle.kts | 72 + store6-devtools-demo/iosApp/.gitignore | 4 + store6-devtools-demo/iosApp/Info.plist | 53 + .../iosApp/iosApp.xcodeproj/project.pbxproj | 361 ++ .../contents.xcworkspacedata | 7 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 + .../iosApp/Assets.xcassets/Contents.json | 6 + .../iosApp/iosApp/ContentView.swift | 10 + .../iosApp/iosApp/iOSApp.swift | 8 + .../src/androidMain/AndroidManifest.xml | 11 + .../store6/devtoolsdemo/MainActivity.kt | 12 + .../store6/devtoolsdemo/DemoApp.kt | 154 + .../store6/devtoolsdemo/DemoFetcherTest.kt | 56 + .../store6/devtoolsdemo/Main.kt | 7 + .../IosAppProjectConfigurationTest.kt | 77 + .../store6/devtoolsdemo/MainViewController.kt | 6 + store6-devtools-inspector/README.md | 72 + .../api/android/store6-devtools-inspector.api | 5 + .../api/jvm/store6-devtools-inspector.api | 5 + .../api/store6-devtools-inspector.klib.api | 10 + store6-devtools-inspector/build.gradle.kts | 63 + store6-devtools-inspector/gradle.properties | 3 + .../src/androidMain/AndroidManifest.xml | 3 +- .../store6/devtools/compose/InspectorState.kt | 35 + .../devtools/compose/InspectorUiState.kt | 147 + .../store6/devtools/compose/StoreInspector.kt | 188 + .../compose/DeriveInspectorUiStateTest.kt | 318 + .../devtools/compose/InspectorStateTest.kt | 81 + .../compose/docs/GuideInspectorSnippet.kt | 24 + .../compose/StoreInspectorSmokeTest.kt | 150 + store6-devtools/EVENTS.md | 90 + store6-devtools/README.md | 100 + .../api/android/store6-devtools.api | 124 + store6-devtools/api/jvm/store6-devtools.api | 124 + store6-devtools/api/store6-devtools.klib.api | 177 + store6-devtools/build.gradle.kts | 23 + store6-devtools/gradle.properties | 3 + .../src/androidMain/AndroidManifest.xml | 3 +- .../devtools/CompositeStoreTelemetry.kt | 65 + .../store6/devtools/DevtoolsSnapshot.kt | 147 + .../store6/devtools/StoreDevtoolsEvent.kt | 84 + .../store6/devtools/StoreDevtoolsMonitor.kt | 164 + .../store6/devtools/StoreTelemetryLogger.kt | 159 + .../devtools/CompositeStoreTelemetryTest.kt | 138 + .../StoreDevtoolsMonitorIntegrationTest.kt | 107 + .../devtools/StoreDevtoolsMonitorTest.kt | 386 ++ .../devtools/StoreTelemetryLoggerTest.kt | 266 + .../devtools/docs/GuideDevtoolsSnippets.kt | 55 + .../devtools/DevtoolsSnapshotJvmTest.kt | 51 + .../StoreTelemetryLoggerJvmConcurrencyTest.kt | 92 + store6-extension-probe/build.gradle.kts | 74 + .../src/androidMain/AndroidManifest.xml | 1 + .../CoordinatedTransactionalSourceOfTruth.kt | 373 ++ .../extensionprobe/KeyEventDescriptions.kt | 20 + .../store6/extensionprobe/LoggingStore.kt | 33 + .../store6/extensionprobe/MetricsTelemetry.kt | 60 + .../store6/extensionprobe/MutationEvents.kt | 36 + ...ordinatedTransactionalSourceOfTruthTest.kt | 1060 ++++ .../extensionprobe/ExtensionProbeTest.kt | 99 + store6-graphql/README.md | 124 + store6-graphql/api/android/store6-graphql.api | 209 + store6-graphql/api/jvm/store6-graphql.api | 209 + store6-graphql/api/store6-graphql.klib.api | 250 + store6-graphql/build.gradle.kts | 25 + store6-graphql/gradle.properties | 3 + store6-graphql/sample/build.gradle.kts | 12 + .../store6/graphql/sample/Main.kt | 176 + .../src/androidMain/AndroidManifest.xml | 2 + .../store6/graphql/Canonicalization.kt | 71 + .../store6/graphql/GraphQlError.kt | 84 + .../store6/graphql/GraphQlExecutor.kt | 76 + .../store6/graphql/GraphQlFetcher.kt | 108 + .../store6/graphql/GraphQlOperation.kt | 40 + .../store6/graphql/GraphQlOperationKey.kt | 61 + .../graphql/GraphQlPartialDataPolicy.kt | 25 + .../store6/graphql/GraphQlValue.kt | 278 + .../graphql/GraphQlCanonicalizationTest.kt | 281 + .../graphql/GraphQlFetcherMappingTest.kt | 252 + .../graphql/GraphQlStoreIntegrationTest.kt | 266 + .../store6/graphql/GraphQlTestFixtures.kt | 73 + store6-mutations-quickstart/build.gradle.kts | 10 + .../src/main/kotlin/Main.kt | 136 + .../android/store6-mutations-sqldelight.api | 5 + .../api/jvm/store6-mutations-sqldelight.api | 5 + .../api/store6-mutations-sqldelight.klib.api | 13 + store6-mutations-sqldelight/build.gradle.kts | 127 + store6-mutations-sqldelight/gradle.properties | 3 + .../src/androidMain/AndroidManifest.xml | 1 + .../sqldelight/JournalHarness.android.kt | 6 + .../SqlDelightMutationJournalStorage.kt | 1653 ++++++ .../internal/MutationJournalSidecar.kt | 308 + .../mutations/sqldelight/JournalHarness.kt | 146 + .../SqlDelightJournalRestartTest.kt | 69 + .../SqlDelightJournalStorageContractTest.kt | 45 + .../SqlDelightJournalStoragePhysicalTest.kt | 279 + .../SqlDelightMutationRestartWalkingTest.kt | 222 + .../internal/MutationJournalSidecarTest.kt | 751 +++ .../JournalStorageInstallDocsSnippet.kt | 69 + .../TestingJournalContractDocsSnippet.kt | 60 + .../sqldelight/FileBackedJournalReopenTest.kt | 145 + .../sqldelight/JournalHarness.jvm.kt | 6 + .../sqldelight/Utf16JournalStorageTest.kt | 86 + .../sqldelight/JournalHarness.native.kt | 6 + .../api/android/store6-mutations-testing.api | 96 + .../api/jvm/store6-mutations-testing.api | 96 + .../api/store6-mutations-testing.klib.api | 380 ++ store6-mutations-testing/build.gradle.kts | 29 + store6-mutations-testing/gradle.properties | 3 + .../src/androidMain/AndroidManifest.xml | 2 + .../JournalStorageKillPointScenarios.kt | 549 ++ .../testing/KillPointJournalStorage.kt | 144 + .../MutationJournalStorageContractKit.kt | 3327 +++++++++++ .../testing/MutatorPurityContractKit.kt | 251 + .../kotlin/MutatorPurityDocsSnippet.kt | 80 + .../InMemoryJournalStorageContractTest.kt | 12 + .../testing/MutatorPurityKitSelfTest.kt | 214 + .../api/android/store6-mutations.api | 549 ++ store6-mutations/api/jvm/store6-mutations.api | 549 ++ .../api/store6-mutations.klib.api | 798 +++ .../api/swift/objc/Store6Mutations.h | 2931 ++++++++++ .../api/swift/skie/Store6MutationsSkie.h | 3494 +++++++++++ .../api/swift/skie/Store6MutationsSkie.swift | 3864 ++++++++++++ store6-mutations/build.gradle.kts | 54 + store6-mutations/dokka/Module.md | 27 + store6-mutations/gradle.properties | 3 + .../src/androidMain/AndroidManifest.xml | 2 + .../store6/mutations/MutationBookkeeper.kt | 200 + .../store6/mutations/MutationEngine.kt | 4790 +++++++++++++++ .../store6/mutations/MutationEvents.kt | 313 + .../store6/mutations/MutationInspection.kt | 240 + .../store6/mutations/MutationJournal.kt | 751 +++ .../store6/mutations/MutationProtocol.kt | 432 ++ .../store6/mutations/MutationSourceOfTruth.kt | 108 + .../store6/mutations/MutationStore.kt | 570 ++ .../store6/mutations/MutationStoreBuilder.kt | 314 + .../store6/mutations/MutatorRegistry.kt | 239 + .../storage/InMemoryMutationJournalStorage.kt | 1297 +++++ .../storage/MutationJournalRecords.kt | 422 ++ .../storage/MutationJournalStorage.kt | 176 + .../kotlin/ConflictPolicyDocsSnippet.kt | 38 + .../kotlin/MutationServerHttpDocsSnippet.kt | 77 + .../kotlin/MutatorRegistrationDocsSnippet.kt | 112 + .../mutations/FailPointJournalStorage.kt | 370 ++ .../mutations/MutationAckOrchestrationTest.kt | 756 +++ .../store6/mutations/MutationAckPathTest.kt | 1314 +++++ .../mutations/MutationAliasFacadeTest.kt | 2092 +++++++ .../store6/mutations/MutationBackoffTest.kt | 609 ++ .../MutationBookkeeperContractTest.kt | 24 + .../store6/mutations/MutationConflictTest.kt | 1604 +++++ .../MutationDrainInvalidationTest.kt | 692 +++ .../mutations/MutationDrainParkingTest.kt | 1007 ++++ .../MutationDrainResumabilityMatrixTest.kt | 1096 ++++ .../MutationDrainSchedulingPropertyTest.kt | 443 ++ .../store6/mutations/MutationDrainTest.kt | 659 +++ .../mutations/MutationDrainTriggerTest.kt | 200 + .../store6/mutations/MutationEffectsTest.kt | 179 + .../mutations/MutationEventSurfaceTest.kt | 260 + .../store6/mutations/MutationEventsTest.kt | 707 +++ .../mutations/MutationInspectionTest.kt | 423 ++ .../mutations/MutationJournalContractTest.kt | 2333 ++++++++ .../store6/mutations/MutationJournalTest.kt | 305 + .../mutations/MutationKeyResolverTest.kt | 307 + .../MutationOverlayReplayPropertyTest.kt | 672 +++ .../store6/mutations/MutationOverlayTest.kt | 377 ++ .../store6/mutations/MutationPresenceTest.kt | 101 + .../store6/mutations/MutationProtocolTest.kt | 724 +++ .../MutationPruningRegressionTest.kt | 864 +++ .../store6/mutations/MutationRebaseTest.kt | 267 + .../mutations/MutationRestartWalkingTest.kt | 144 + .../mutations/MutationRetirementTest.kt | 1702 ++++++ .../MutationSourceOfTruthContractTest.kt | 46 + .../mutations/MutationStoreBuilderTest.kt | 435 ++ .../store6/mutations/MutationsTestFixtures.kt | 311 + .../mutations/MutationsWalkingSkeletonTest.kt | 254 + .../mutations/MutationsWiringSpikeTest.kt | 86 + .../store6/mutations/MutatorSugarTest.kt | 255 + .../docs/MutationDocsSnippetFixtures.kt | 172 + .../docs/MutationOverviewDocsSnippet.kt | 63 + .../MutationServerContractDocsSnippets.kt | 20 + .../mutations/MutationApiSurfaceTest.kt | 73 + .../mutations/MutationJournalLincheckTest.kt | 589 ++ .../api/android/store6-paging-androidx.api | 24 + .../api/jvm/store6-paging-androidx.api | 24 + .../api/store6-paging-androidx.klib.api | 30 + store6-paging-androidx/build.gradle.kts | 56 + store6-paging-androidx/gradle.properties | 3 + .../sample/build.gradle.kts | 13 + .../store6/paging/sample/Main.kt | 600 ++ .../src/androidMain/AndroidManifest.xml | 2 + .../store6/paging/GenerationWatcher.kt | 222 + .../store6/paging/PagingSourceFactory.kt | 34 + .../store6/paging/StorePagingBuilder.kt | 102 + .../store6/paging/StorePagingConfig.kt | 17 + .../store6/paging/StorePagingSource.kt | 119 + .../store6/paging/StoreRemoteMediator.kt | 90 + .../store6/paging/PagingInvalidationTest.kt | 576 ++ .../store6/paging/PagingMutationTracerTest.kt | 463 ++ .../store6/paging/PagingTestFixtures.kt | 148 + .../store6/paging/StorePagingSourceTest.kt | 507 ++ .../store6/paging/StoreRemoteMediatorTest.kt | 339 ++ store6-quickstart/build.gradle.kts | 10 + .../store6/quickstart/Main.kt | 67 + store6-realtime/README.md | 105 + .../api/android/store6-realtime.api | 48 + store6-realtime/api/jvm/store6-realtime.api | 48 + store6-realtime/api/store6-realtime.klib.api | 64 + store6-realtime/build.gradle.kts | 27 + store6-realtime/gradle.properties | 3 + store6-realtime/sample/build.gradle.kts | 12 + .../store6/realtime/sample/Main.kt | 131 + .../src/androidMain/AndroidManifest.xml | 2 + .../store6/realtime/RealtimeBinding.kt | 90 + .../store6/realtime/RealtimeBindings.kt | 38 + .../store6/realtime/RealtimeMessage.kt | 88 + .../store6/realtime/RealtimeAdoptionTest.kt | 178 + .../realtime/RealtimeBindingLifecycleTest.kt | 199 + .../realtime/RealtimeInvalidationTest.kt | 257 + .../store6/realtime/RealtimeTestFixtures.kt | 114 + store6-room/README.md | 221 + store6-room/api/android/store6-room.api | 58 + store6-room/api/jvm/store6-room.api | 58 + store6-room/api/store6-room.klib.api | 81 + store6-room/build.gradle.kts | 87 + store6-room/gradle.properties | 3 + store6-room/sample/build.gradle.kts | 24 + .../store6/room/sample/Main.kt | 310 + .../src/androidMain/AndroidManifest.xml | 2 + .../store6/room/RoomBookkeeper.kt | 244 + .../store6/room/RoomSourceOfTruth.kt | 628 ++ .../store6/room/RoomStoreMeta.kt | 8 + .../store6/room/Store6BookkeeperDao.kt | 44 + .../store6/room/Store6BookkeepingEntity.kt | 36 + .../store6/room/Store6RoomSchema.kt | 37 + .../store6/room/Store6WatermarkEntity.kt | 21 + .../store6/room/RoomBookkeeperContractTest.kt | 31 + .../store6/room/RoomBookkeeperTest.kt | 249 + .../room/RoomSourceOfTruthContractTest.kt | 57 + .../RoomSourceOfTruthReaderSemanticsTest.kt | 657 +++ .../RoomStoreSubstitutionConformanceTest.kt | 628 ++ .../RoomTransactionalSourceOfTruthTest.kt | 394 ++ .../room/Store6RoomSchemaMigrationTest.kt | 81 + .../store6/room/TestDatabase.kt | 133 + .../store6/room/TestKeys.kt | 11 + .../store6/room/TestDatabase.jvm.kt | 28 + .../store6/room/TestDatabase.native.kt | 33 + store6-sqldelight/README.md | 131 + .../api/android/store6-sqldelight.api | 24 + .../api/jvm/store6-sqldelight.api | 24 + .../api/store6-sqldelight.klib.api | 32 + store6-sqldelight/build.gradle.kts | 146 + store6-sqldelight/gradle.properties | 3 + store6-sqldelight/sample/build.gradle.kts | 22 + .../store6/sqldelight/sample/Main.kt | 106 + .../store6/sqldelight/sample/db/User.sq | 21 + .../src/androidMain/AndroidManifest.xml | 2 + .../internal/ExecutionThread.android.kt | 4 + .../internal/SystemEpochMillis.android.kt | 4 + .../store6/sqldelight/FreshHarness.android.kt | 9 + .../internal/ExecutionThread.apple.kt | 8 + .../internal/SystemEpochMillis.apple.kt | 8 + .../store6/sqldelight/SqlDelightBookkeeper.kt | 129 + .../sqldelight/SqlDelightSourceOfTruth.kt | 223 + .../sqldelight/internal/DriverAccess.kt | 107 + .../sqldelight/internal/ExecutionThread.kt | 4 + .../store6/sqldelight/internal/MetaSidecar.kt | 310 + .../internal/SynchronousTransactions.kt | 50 + .../sqldelight/internal/SystemEpochMillis.kt | 15 + .../cash/turbine/BorrowedTurbineTimeout.kt | 37 + .../store6/core/BorrowedSuiteSupport.kt | 116 + .../sqldelight/SqlDelightAtomicityTest.kt | 249 + .../SqlDelightBookkeeperContractTest.kt | 13 + .../SqlDelightBookkeeperRestartTest.kt | 89 + .../sqldelight/SqlDelightCrashWindowTest.kt | 105 + .../SqlDelightDurableMaintenanceTest.kt | 152 + .../SqlDelightSchedulerPerturbationRuns.kt | 94 + .../SqlDelightSourceOfTruthContractTest.kt | 123 + .../sqldelight/SqlDelightSubstitutionRuns.kt | 152 + .../sqldelight/SqlDelightTestFixtures.kt | 49 + .../store6/sqldelight/SqlHarness.kt | 250 + .../sqldelight/internal/MetaSidecarTest.kt | 382 ++ .../internal/SynchronousTransactionsTest.kt | 57 + .../sqldelight/internal/ExecutionThread.js.kt | 4 + .../internal/SystemEpochMillis.js.kt | 6 + .../internal/ExecutionThread.jvm.kt | 4 + .../internal/SystemEpochMillis.jvm.kt | 4 + .../DriverAccessConcurrencyJvmTest.kt | 347 ++ .../store6/sqldelight/FreshHarness.jvm.kt | 9 + .../internal/ExecutionThread.linux.kt | 8 + .../internal/SystemEpochMillis.linux.kt | 18 + .../internal/ExecutionThread.mingw.kt | 8 + .../internal/SystemEpochMillis.mingw.kt | 21 + .../store6/sqldelight/FreshHarness.native.kt | 5 + .../internal/ExecutionThread.wasmJs.kt | 4 + .../internal/SystemEpochMillis.wasmJs.kt | 6 + .../mutations-objc/build.gradle.kts | 36 + .../mutationsobjc/SwiftDumpAnchor.kt | 3 + .../mutations-skie/build.gradle.kts | 38 + .../mutationsskie/SwiftDumpAnchor.kt | 3 + store6-swift-dumps/objc/build.gradle.kts | 21 + .../store6/swiftdump/objc/SwiftDumpAnchor.kt | 3 + store6-swift-dumps/skie/build.gradle.kts | 22 + .../store6/swiftdump/skie/SwiftDumpAnchor.kt | 3 + store6-testing/api/android/store6-testing.api | 192 + store6-testing/api/jvm/store6-testing.api | 192 + store6-testing/api/store6-testing.klib.api | 386 ++ store6-testing/build.gradle.kts | 49 + store6-testing/gradle.properties | 3 + .../src/androidMain/AndroidManifest.xml | 2 + .../store6/testing/BookkeeperContractKit.kt | 165 + .../store6/testing/FakeBookkeeper.kt | 103 + .../store6/testing/FakeFetcher.kt | 71 + .../store6/testing/FakeSourceOfTruth.kt | 72 + .../store6/testing/FakeStore.kt | 520 ++ .../store6/testing/FakeStoreInteraction.kt | 46 + .../testing/SourceOfTruthContractKit.kt | 315 + .../store6/testing/TestStoreMeta.kt | 11 + .../store6/testing/TestStoreResults.kt | 71 + .../store6/testing/TestWallClock.kt | 27 + .../testing/FakeBookkeeperAlgebraTest.kt | 79 + .../testing/FakeBookkeeperContractTest.kt | 10 + .../testing/FakeBookkeeperRestartTest.kt | 34 + .../testing/FakeFetcherIntegrationTest.kt | 46 + .../testing/FakeSourceOfTruthContractTest.kt | 14 + .../testing/FakeStoreConformanceTest.kt | 411 ++ .../store6/testing/TestStoreResultsTest.kt | 33 + .../store6/testing/TestingKey.kt | 9 + .../store6/testing/UserViewModelSampleTest.kt | 62 + .../testing/docs/GuideTestingSnippets.kt | 79 + tooling/plugins/build.gradle.kts | 58 +- .../plugins/AndroidConventionPlugin.kt | 35 +- .../plugins/FormattingConventionPlugin.kt | 35 - .../KotlinMultiplatformConventionPlugin.kt | 265 +- .../tooling/plugins/Store6Conventions.kt | 125 + .../Store6MultiplatformConventionPlugin.kt | 33 + ...ore6MultiplatformSubsetConventionPlugin.kt | 15 + .../tooling/plugins/SwiftDumpTasksPlugin.kt | 408 ++ .../tooling/plugins/VersionCatalogExt.kt | 26 - .../plugins/SwiftDumpTasksPluginTest.kt | 306 + 714 files changed, 126382 insertions(+), 15032 deletions(-) create mode 100644 .claude-plugin/marketplace.json create mode 100644 .claude/skills/code-documentation/SKILL.md create mode 100644 .claude/skills/code-documentation/references/evidence-and-verification.md create mode 100644 .claude/skills/code-documentation/references/languages/go.md create mode 100644 .claude/skills/code-documentation/references/languages/java.md create mode 100644 .claude/skills/code-documentation/references/languages/kotlin.md create mode 100644 .claude/skills/code-documentation/references/languages/python.md create mode 100644 .claude/skills/code-documentation/references/languages/rust.md create mode 100644 .claude/skills/code-documentation/references/languages/typescript-javascript.md create mode 100644 .claude/skills/code-documentation/references/surfaces/implementation-documentation.md create mode 100644 .claude/skills/code-documentation/references/surfaces/inline-documentation.md create mode 100644 .claude/skills/code-documentation/references/surfaces/interface-documentation.md create mode 100644 .claude/skills/code-documentation/references/surfaces/package-readme.md create mode 100644 .claude/skills/code-documentation/references/surfaces/repository-readme.md create mode 100644 .claude/skills/documentation-discipline/SKILL.md create mode 100644 .claude/skills/documentation-discipline/references/discipline-rules.md delete mode 100644 .editorconfig create mode 100644 .github/docs-sync-sources.txt delete mode 100644 .github/workflows/KMMBridge-Release.yml delete mode 100644 .github/workflows/create_swift_package.yml create mode 100644 .github/workflows/store6-benchmarks.yml create mode 100644 .github/workflows/store6-full-jvm.yml create mode 100644 .github/workflows/store6.yml create mode 100644 AGENTS.md create mode 100644 ROADMAP.md create mode 100644 STABILITY.md delete mode 100644 cache/README.md delete mode 100644 cache/api/jvm/cache.api delete mode 100644 cache/build.gradle.kts delete mode 100644 cache/config/ktlint/baseline.xml delete mode 100644 cache/gradle.properties delete mode 100644 cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Cache.kt delete mode 100644 cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/CacheBuilder.kt delete mode 100644 cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/LocalCache.kt delete mode 100644 cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/MonotonicTicker.kt delete mode 100644 cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/RemovalCause.kt delete mode 100644 cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCache.kt delete mode 100644 cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCacheAccessor.kt delete mode 100644 cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Ticker.kt delete mode 100644 cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Weigher.kt delete mode 100644 cache/src/commonTest/kotlin/org/mobilenativefoundation/store/cache5/CacheTests.kt delete mode 100644 core/api/jvm/core.api delete mode 100644 core/build.gradle.kts delete mode 100644 core/gradle.properties delete mode 100644 core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/ExperimentalStoreApi.kt delete mode 100644 core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/InsertionStrategy.kt delete mode 100644 core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/KeyProvider.kt delete mode 100644 core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreData.kt delete mode 100644 core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreKey.kt create mode 100644 docs/store6/important-defaults.md create mode 100644 docs/store6/invalidate-vs-clear.md create mode 100644 docs/store6/key-design.md create mode 100644 docs/store6/quickstart.md create mode 100644 docs/superpowers/plans/2026-08-06-store6-alpha01-source-doc-cleanup.md create mode 100644 docs/superpowers/plans/2026-08-06-store6-doc-cleanup-inventory.md create mode 100644 docs/superpowers/plans/2026-08-15-store38-building-a-store6-data-layer-skill.md delete mode 100644 gradle/gradle-daemon-jvm.properties create mode 100644 llms.txt delete mode 100644 multicast/api/jvm/multicast.api delete mode 100644 multicast/build.gradle.kts delete mode 100644 multicast/gradle.properties delete mode 100644 multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/Actor.kt delete mode 100644 multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/ChannelManager.kt delete mode 100644 multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/Multicaster.kt delete mode 100644 multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/SharedFlowProducer.kt delete mode 100644 multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/StoreRealActor.kt delete mode 100644 multicast/src/commonTest/kotlin/org/mobilenativefoundation/store/multicast5/StoreChannelManagerTests.kt create mode 100644 plugins/store/.claude-plugin/plugin.json create mode 100644 plugins/store/README.md create mode 100644 plugins/store/evals/building-a-store6-data-layer/fixtures/AtlasApi.kt create mode 100644 plugins/store/evals/building-a-store6-data-layer/fixtures/Db.kt create mode 100644 plugins/store/evals/building-a-store6-data-layer/fixtures/ProfileViewModel.kt create mode 100644 plugins/store/evals/building-a-store6-data-layer/fixtures/REQUIREMENTS.md create mode 100644 plugins/store/evals/building-a-store6-data-layer/results.md create mode 100644 plugins/store/evals/building-a-store6-data-layer/scenario.md create mode 100644 plugins/store/evals/migrating-to-store6/fixtures/UserRepository.kt create mode 100644 plugins/store/evals/migrating-to-store6/results.md create mode 100644 plugins/store/evals/migrating-to-store6/scenario.md create mode 100644 plugins/store/skills/building-a-store6-data-layer/SKILL.md create mode 100644 plugins/store/skills/building-a-store6-data-layer/references/compose.md create mode 100644 plugins/store/skills/building-a-store6-data-layer/references/room.md create mode 100644 plugins/store/skills/building-a-store6-data-layer/references/sqldelight.md create mode 100644 plugins/store/skills/building-a-store6-data-layer/references/swift.md create mode 100644 plugins/store/skills/migrating-to-store6/SKILL.md create mode 100644 plugins/store/skills/migrating-to-store6/references/component-map.md create mode 100644 plugins/store/skills/migrating-to-store6/references/from-store4.md create mode 100644 plugins/store/skills/migrating-to-store6/references/mutations.md create mode 100644 plugins/store/skills/migrating-to-store6/references/store5-to-store6.md delete mode 100644 rx2/api/rx2.api delete mode 100644 rx2/build.gradle.kts delete mode 100644 rx2/gradle.properties delete mode 100644 rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxFetcher.kt delete mode 100644 rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxSourceOfTruth.kt delete mode 100644 rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxStore.kt delete mode 100644 rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxStoreBuilder.kt delete mode 100644 rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/FlowTestExt.kt delete mode 100644 rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/HotRxSingleStoreTest.kt delete mode 100644 rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/RxFlowableStoreTest.kt delete mode 100644 rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/RxSingleStoreExtensionsTest.kt delete mode 100644 rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/RxSingleStoreTest.kt delete mode 100644 store/api/jvm/store.api delete mode 100644 store/build.gradle.kts delete mode 100644 store/config/ktlint/baseline.xml delete mode 100644 store/gradle.properties delete mode 100644 store/src/androidMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.android.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Bookkeeper.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Clear.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Converter.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Fetcher.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/FetcherResult.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Logger.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/MemoryPolicy.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/MutableStore.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/MutableStoreBuilder.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/OnFetcherCompletion.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/OnUpdaterCompletion.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Read.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/SourceOfTruth.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Store.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreBuilder.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreDefaults.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreReadRequest.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreReadResponse.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreWriteRequest.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreWriteResponse.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Updater.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/UpdaterResult.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Validator.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Write.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/DefaultLogger.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/FetcherController.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/OnStoreWriteCompletion.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealBookkeeper.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealMutableStore.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealMutableStoreBuilder.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealSourceOfTruth.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStore.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStoreBuilder.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStoreWriteRequest.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealValidator.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RefCountedResource.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/SourceOfTruthWithBarrier.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/store.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/operators/FlowMerge.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/operators/MapIndexed.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/concurrent/ThreadSafety.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/definition/Timestamp.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/definition/WriteRequestQueue.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult.kt delete mode 100644 store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/storeBuilder.uml delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/ClearAllStoreTests.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/ClearStoreByKeyTests.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FallbackTests.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FetcherControllerTests.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FetcherResponseTests.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FlowStoreTests.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/HotFlowStoreTests.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/KeyTrackerTests.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/LocalOnlyTests.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/MapIndexedTests.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/SourceOfTruthErrorsTests.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/SourceOfTruthWithBarrierTests.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/StoreReadResponseTests.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/StoreWithInMemoryCacheTests.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/StreamWithoutSourceOfTruthTests.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/UpdaterTests.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/ValueFetcherTests.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/MutableStoreConcurrencyTest.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/RealMutableStoreTest.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestCache.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestConverter.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestFetcher.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestInMemoryBookkeeper.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestLogger.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestSourceOfTruth.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestStore.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestUpdater.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestValidator.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/AsFlowable.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/FakeFetcher.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/InMemoryPersister.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/TestApi.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/TestStoreExt.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NoteCollections.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/Notes.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesApi.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesBookkeeping.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesConverterProvider.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesDatabase.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesKey.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesUpdaterProvider.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesValidator.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/HardcodedPages.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/Page.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/PagesDatabase.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/PrimaryPagesApi.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/SecondaryPagesApi.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/model/NoteData.kt delete mode 100644 store/src/iosMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.ios.kt delete mode 100644 store/src/jsMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.js.kt delete mode 100644 store/src/jvmMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.jvm.kt delete mode 100644 store/src/linuxX64Main/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.linuxX64.kt delete mode 100644 store/src/wasmJsMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.wasmJs.kt create mode 100644 store6-benchmarks/README.md create mode 100644 store6-benchmarks/build.gradle.kts create mode 100644 store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/BenchKey.kt create mode 100644 store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/ColdStartBenchmark.kt create mode 100644 store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/GetPathBenchmark.kt create mode 100644 store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/NoopTelemetry.kt create mode 100644 store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/StreamEmissionBenchmark.kt create mode 100644 store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/SubscriptionChurnBenchmark.kt create mode 100644 store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/TelemetryOverheadBenchmark.kt create mode 100644 store6-benchmarks/src/test/kotlin/org/mobilenativefoundation/store6/benchmarks/HarnessSmokeTest.kt create mode 100644 store6-benchmarks/src/test/kotlin/org/mobilenativefoundation/store6/benchmarks/TelemetryAllocationProbe.kt create mode 100644 store6-compose-demo/build.gradle.kts create mode 100644 store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/DemoScreen.kt create mode 100644 store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/DemoStore.kt create mode 100644 store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/DemoUiState.kt create mode 100644 store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/Main.kt create mode 100644 store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/StabilityProbe.kt create mode 100644 store6-compose-demo/src/test/kotlin/org/mobilenativefoundation/store6/composedemo/DemoFetcherTest.kt create mode 100644 store6-compose-demo/src/test/kotlin/org/mobilenativefoundation/store6/composedemo/DemoUiStateTest.kt create mode 100644 store6-compose/README.md create mode 100644 store6-compose/api/android/store6-compose.api create mode 100644 store6-compose/api/jvm/store6-compose.api create mode 100644 store6-compose/api/store6-compose.klib.api create mode 100644 store6-compose/build.gradle.kts create mode 100644 store6-compose/gradle.properties rename {rx2/src/main => store6-compose/src/androidMain}/AndroidManifest.xml (100%) create mode 100644 store6-compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/CollectAsState.kt create mode 100644 store6-compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/CollectAsStateWithLifecycle.kt create mode 100644 store6-compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/StoreResultEquivalence.kt create mode 100644 store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/ClosedStoreBehaviorTest.kt create mode 100644 store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/CollectAsStateTest.kt create mode 100644 store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/CollectAsStateWithLifecycleTest.kt create mode 100644 store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/ComposeTestHarness.kt create mode 100644 store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/SkipEqualDataTest.kt create mode 100644 store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/StoreResultMutationPolicyTest.kt create mode 100644 store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/docs/PendingWriteUiDocsSnippet.kt create mode 100644 store6-compose/stability/store6-stability.conf create mode 100644 store6-core/api/android/store6-core.api create mode 100644 store6-core/api/jvm/store6-core.api create mode 100644 store6-core/api/store6-core.klib.api create mode 100644 store6-core/api/swift/objc/Store6Core.h create mode 100644 store6-core/api/swift/skie/Store6CoreSkie.h create mode 100644 store6-core/api/swift/skie/Store6CoreSkie.swift create mode 100644 store6-core/build.gradle.kts create mode 100644 store6-core/dokka/Module.md create mode 100644 store6-core/gradle.properties rename core/config/ktlint/baseline.xml => store6-core/src/androidMain/AndroidManifest.xml (51%) create mode 100644 store6-core/src/androidMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.android.kt create mode 100644 store6-core/src/appleMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.apple.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Annotations.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Freshness.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Origin.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Store.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreBuilder.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreError.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreException.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreKey.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreMeta.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreResult.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/Bookkeeper.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/EngineResidency.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/FetchSlot.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/FetcherAdapters.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/FreshnessValidator.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/InMemorySourceOfTruth.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyEngine.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyId.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyRegistry.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyState.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/MaintenanceCoordinator.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/Projection.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/ReaderRecord.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/RealStore.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/RealStoreRuntime.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/StoreLifecycle.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/StoreResultFlows.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/Transitions.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/ValueEnvelope.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Bookkeeper.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Fetcher.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FetcherResult.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FreshnessValidator.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/KeyEvents.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Overlay.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/SourceOfTruth.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreResults.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreRuntime.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreTelemetry.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreWriteHandle.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/TransactionalSourceOfTruth.kt create mode 100644 store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/WallClock.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/EmissionSequenceConformanceTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/FetcherContractTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/FreshnessPolicyConformanceTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/KeyEngineSourceOfTruthRaceTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/MaintenanceTestFakes.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/NamespacedTestKey.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/OverlayConformanceTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/PublicSurfaceTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/ReaderGenerationRecoveryTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/ReaderHopSourceOfTruth.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SchedulerPerturbationRuns.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SeamFetcherTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SingleFlightConformanceTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthAdditionalRaceTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthBindingConformanceTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthCancellationConformanceTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthConformanceTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthFailureConformanceTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthHydrationRaceTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthSubstitutionTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreBackpressureConformanceTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreBuilderPersistenceTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreCloseLifecycleTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConfigSeamsTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConformanceSubstitutionRuns.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConformanceTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreDefaultsPinTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreDurableMaintenanceConformanceTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreDurableMaintenanceFailureTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreEvictionConformanceTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreEvictionStressTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreInvalidationConformanceTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreInvalidationStressTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreResultsTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreRevalidationConformanceTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreRuntimeTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreScopedMaintenanceRaceTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreTelemetryTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreZeroConfigEquivalenceTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/TestBarriers.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/TestClocks.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/TestKey.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/docs/GuideSnippetCompilation.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/BookkeeperOrderingConformanceTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/ConflateLatestDataTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/FreshnessValidatorTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/InMemoryBookkeeperKitTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/InMemoryBookkeeperTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/InMemorySourceOfTruthKitTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/InMemorySourceOfTruthTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/KeyEnginePlanningTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/KeyRegistryTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/MaintenanceCoordinatorTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/OverlayProjectionProtocolTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/ProjectionAuthorizationHandoffTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/ProjectionRecomputeVisibilityProbeTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/ReaderRecordResolutionTest.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/RotatingSlotSourceOfTruth.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/SharedFlowSourceOfTruth.kt create mode 100644 store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/TransitionTest.kt create mode 100644 store6-core/src/jsMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.js.kt create mode 100644 store6-core/src/jvmMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.jvm.kt create mode 100644 store6-core/src/jvmTest/kotlin/org/mobilenativefoundation/store6/core/FetcherDispatcherTest.kt create mode 100644 store6-core/src/linuxMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.linux.kt create mode 100644 store6-core/src/mingwMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.mingw.kt create mode 100644 store6-core/src/wasmJsMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.wasmJs.kt create mode 100644 store6-devtools-demo/README.md create mode 100644 store6-devtools-demo/build.gradle.kts create mode 100644 store6-devtools-demo/iosApp/.gitignore create mode 100644 store6-devtools-demo/iosApp/Info.plist create mode 100644 store6-devtools-demo/iosApp/iosApp.xcodeproj/project.pbxproj create mode 100644 store6-devtools-demo/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 store6-devtools-demo/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 store6-devtools-demo/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 store6-devtools-demo/iosApp/iosApp/Assets.xcassets/Contents.json create mode 100644 store6-devtools-demo/iosApp/iosApp/ContentView.swift create mode 100644 store6-devtools-demo/iosApp/iosApp/iOSApp.swift create mode 100644 store6-devtools-demo/src/androidMain/AndroidManifest.xml create mode 100644 store6-devtools-demo/src/androidMain/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/MainActivity.kt create mode 100644 store6-devtools-demo/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/DemoApp.kt create mode 100644 store6-devtools-demo/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/DemoFetcherTest.kt create mode 100644 store6-devtools-demo/src/desktopMain/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/Main.kt create mode 100644 store6-devtools-demo/src/desktopTest/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/IosAppProjectConfigurationTest.kt create mode 100644 store6-devtools-demo/src/iosMain/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/MainViewController.kt create mode 100644 store6-devtools-inspector/README.md create mode 100644 store6-devtools-inspector/api/android/store6-devtools-inspector.api create mode 100644 store6-devtools-inspector/api/jvm/store6-devtools-inspector.api create mode 100644 store6-devtools-inspector/api/store6-devtools-inspector.klib.api create mode 100644 store6-devtools-inspector/build.gradle.kts create mode 100644 store6-devtools-inspector/gradle.properties rename multicast/config/ktlint/baseline.xml => store6-devtools-inspector/src/androidMain/AndroidManifest.xml (51%) create mode 100644 store6-devtools-inspector/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtools/compose/InspectorState.kt create mode 100644 store6-devtools-inspector/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtools/compose/InspectorUiState.kt create mode 100644 store6-devtools-inspector/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtools/compose/StoreInspector.kt create mode 100644 store6-devtools-inspector/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtools/compose/DeriveInspectorUiStateTest.kt create mode 100644 store6-devtools-inspector/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtools/compose/InspectorStateTest.kt create mode 100644 store6-devtools-inspector/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtools/compose/docs/GuideInspectorSnippet.kt create mode 100644 store6-devtools-inspector/src/jvmTest/kotlin/org/mobilenativefoundation/store6/devtools/compose/StoreInspectorSmokeTest.kt create mode 100644 store6-devtools/EVENTS.md create mode 100644 store6-devtools/README.md create mode 100644 store6-devtools/api/android/store6-devtools.api create mode 100644 store6-devtools/api/jvm/store6-devtools.api create mode 100644 store6-devtools/api/store6-devtools.klib.api create mode 100644 store6-devtools/build.gradle.kts create mode 100644 store6-devtools/gradle.properties rename rx2/config/ktlint/baseline.xml => store6-devtools/src/androidMain/AndroidManifest.xml (51%) create mode 100644 store6-devtools/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtools/CompositeStoreTelemetry.kt create mode 100644 store6-devtools/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtools/DevtoolsSnapshot.kt create mode 100644 store6-devtools/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtools/StoreDevtoolsEvent.kt create mode 100644 store6-devtools/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtools/StoreDevtoolsMonitor.kt create mode 100644 store6-devtools/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtools/StoreTelemetryLogger.kt create mode 100644 store6-devtools/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtools/CompositeStoreTelemetryTest.kt create mode 100644 store6-devtools/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtools/StoreDevtoolsMonitorIntegrationTest.kt create mode 100644 store6-devtools/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtools/StoreDevtoolsMonitorTest.kt create mode 100644 store6-devtools/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtools/StoreTelemetryLoggerTest.kt create mode 100644 store6-devtools/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtools/docs/GuideDevtoolsSnippets.kt create mode 100644 store6-devtools/src/jvmTest/kotlin/org/mobilenativefoundation/store6/devtools/DevtoolsSnapshotJvmTest.kt create mode 100644 store6-devtools/src/jvmTest/kotlin/org/mobilenativefoundation/store6/devtools/StoreTelemetryLoggerJvmConcurrencyTest.kt create mode 100644 store6-extension-probe/build.gradle.kts create mode 100644 store6-extension-probe/src/androidMain/AndroidManifest.xml create mode 100644 store6-extension-probe/src/commonMain/kotlin/org/mobilenativefoundation/store6/extensionprobe/CoordinatedTransactionalSourceOfTruth.kt create mode 100644 store6-extension-probe/src/commonMain/kotlin/org/mobilenativefoundation/store6/extensionprobe/KeyEventDescriptions.kt create mode 100644 store6-extension-probe/src/commonMain/kotlin/org/mobilenativefoundation/store6/extensionprobe/LoggingStore.kt create mode 100644 store6-extension-probe/src/commonMain/kotlin/org/mobilenativefoundation/store6/extensionprobe/MetricsTelemetry.kt create mode 100644 store6-extension-probe/src/commonMain/kotlin/org/mobilenativefoundation/store6/extensionprobe/MutationEvents.kt create mode 100644 store6-extension-probe/src/commonTest/kotlin/org/mobilenativefoundation/store6/extensionprobe/CoordinatedTransactionalSourceOfTruthTest.kt create mode 100644 store6-extension-probe/src/commonTest/kotlin/org/mobilenativefoundation/store6/extensionprobe/ExtensionProbeTest.kt create mode 100644 store6-graphql/README.md create mode 100644 store6-graphql/api/android/store6-graphql.api create mode 100644 store6-graphql/api/jvm/store6-graphql.api create mode 100644 store6-graphql/api/store6-graphql.klib.api create mode 100644 store6-graphql/build.gradle.kts create mode 100644 store6-graphql/gradle.properties create mode 100644 store6-graphql/sample/build.gradle.kts create mode 100644 store6-graphql/sample/src/main/kotlin/org/mobilenativefoundation/store6/graphql/sample/Main.kt create mode 100644 store6-graphql/src/androidMain/AndroidManifest.xml create mode 100644 store6-graphql/src/commonMain/kotlin/org/mobilenativefoundation/store6/graphql/Canonicalization.kt create mode 100644 store6-graphql/src/commonMain/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlError.kt create mode 100644 store6-graphql/src/commonMain/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlExecutor.kt create mode 100644 store6-graphql/src/commonMain/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlFetcher.kt create mode 100644 store6-graphql/src/commonMain/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlOperation.kt create mode 100644 store6-graphql/src/commonMain/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlOperationKey.kt create mode 100644 store6-graphql/src/commonMain/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlPartialDataPolicy.kt create mode 100644 store6-graphql/src/commonMain/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlValue.kt create mode 100644 store6-graphql/src/commonTest/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlCanonicalizationTest.kt create mode 100644 store6-graphql/src/commonTest/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlFetcherMappingTest.kt create mode 100644 store6-graphql/src/commonTest/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlStoreIntegrationTest.kt create mode 100644 store6-graphql/src/commonTest/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlTestFixtures.kt create mode 100644 store6-mutations-quickstart/build.gradle.kts create mode 100644 store6-mutations-quickstart/src/main/kotlin/Main.kt create mode 100644 store6-mutations-sqldelight/api/android/store6-mutations-sqldelight.api create mode 100644 store6-mutations-sqldelight/api/jvm/store6-mutations-sqldelight.api create mode 100644 store6-mutations-sqldelight/api/store6-mutations-sqldelight.klib.api create mode 100644 store6-mutations-sqldelight/build.gradle.kts create mode 100644 store6-mutations-sqldelight/gradle.properties create mode 100644 store6-mutations-sqldelight/src/androidMain/AndroidManifest.xml create mode 100644 store6-mutations-sqldelight/src/androidUnitTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/JournalHarness.android.kt create mode 100644 store6-mutations-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/SqlDelightMutationJournalStorage.kt create mode 100644 store6-mutations-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/internal/MutationJournalSidecar.kt create mode 100644 store6-mutations-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/JournalHarness.kt create mode 100644 store6-mutations-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/SqlDelightJournalRestartTest.kt create mode 100644 store6-mutations-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/SqlDelightJournalStorageContractTest.kt create mode 100644 store6-mutations-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/SqlDelightJournalStoragePhysicalTest.kt create mode 100644 store6-mutations-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/SqlDelightMutationRestartWalkingTest.kt create mode 100644 store6-mutations-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/internal/MutationJournalSidecarTest.kt create mode 100644 store6-mutations-sqldelight/src/jvmTest/kotlin/JournalStorageInstallDocsSnippet.kt create mode 100644 store6-mutations-sqldelight/src/jvmTest/kotlin/TestingJournalContractDocsSnippet.kt create mode 100644 store6-mutations-sqldelight/src/jvmTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/FileBackedJournalReopenTest.kt create mode 100644 store6-mutations-sqldelight/src/jvmTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/JournalHarness.jvm.kt create mode 100644 store6-mutations-sqldelight/src/jvmTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/Utf16JournalStorageTest.kt create mode 100644 store6-mutations-sqldelight/src/nativeSqlTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/JournalHarness.native.kt create mode 100644 store6-mutations-testing/api/android/store6-mutations-testing.api create mode 100644 store6-mutations-testing/api/jvm/store6-mutations-testing.api create mode 100644 store6-mutations-testing/api/store6-mutations-testing.klib.api create mode 100644 store6-mutations-testing/build.gradle.kts create mode 100644 store6-mutations-testing/gradle.properties create mode 100644 store6-mutations-testing/src/androidMain/AndroidManifest.xml create mode 100644 store6-mutations-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/testing/JournalStorageKillPointScenarios.kt create mode 100644 store6-mutations-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/testing/KillPointJournalStorage.kt create mode 100644 store6-mutations-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/testing/MutationJournalStorageContractKit.kt create mode 100644 store6-mutations-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/testing/MutatorPurityContractKit.kt create mode 100644 store6-mutations-testing/src/commonTest/kotlin/MutatorPurityDocsSnippet.kt create mode 100644 store6-mutations-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/testing/InMemoryJournalStorageContractTest.kt create mode 100644 store6-mutations-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/testing/MutatorPurityKitSelfTest.kt create mode 100644 store6-mutations/api/android/store6-mutations.api create mode 100644 store6-mutations/api/jvm/store6-mutations.api create mode 100644 store6-mutations/api/store6-mutations.klib.api create mode 100644 store6-mutations/api/swift/objc/Store6Mutations.h create mode 100644 store6-mutations/api/swift/skie/Store6MutationsSkie.h create mode 100644 store6-mutations/api/swift/skie/Store6MutationsSkie.swift create mode 100644 store6-mutations/build.gradle.kts create mode 100644 store6-mutations/dokka/Module.md create mode 100644 store6-mutations/gradle.properties create mode 100644 store6-mutations/src/androidMain/AndroidManifest.xml create mode 100644 store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationBookkeeper.kt create mode 100644 store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt create mode 100644 store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEvents.kt create mode 100644 store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspection.kt create mode 100644 store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt create mode 100644 store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocol.kt create mode 100644 store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationSourceOfTruth.kt create mode 100644 store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt create mode 100644 store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt create mode 100644 store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutatorRegistry.kt create mode 100644 store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/storage/InMemoryMutationJournalStorage.kt create mode 100644 store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/storage/MutationJournalRecords.kt create mode 100644 store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/storage/MutationJournalStorage.kt create mode 100644 store6-mutations/src/commonTest/kotlin/ConflictPolicyDocsSnippet.kt create mode 100644 store6-mutations/src/commonTest/kotlin/MutationServerHttpDocsSnippet.kt create mode 100644 store6-mutations/src/commonTest/kotlin/MutatorRegistrationDocsSnippet.kt create mode 100644 store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/FailPointJournalStorage.kt create mode 100644 store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAckOrchestrationTest.kt create mode 100644 store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAckPathTest.kt create mode 100644 store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt create mode 100644 store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationBackoffTest.kt create mode 100644 store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationBookkeeperContractTest.kt create mode 100644 store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationConflictTest.kt create mode 100644 store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationDrainInvalidationTest.kt create mode 100644 store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationDrainParkingTest.kt create mode 100644 store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationDrainResumabilityMatrixTest.kt create mode 100644 store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationDrainSchedulingPropertyTest.kt create mode 100644 store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationDrainTest.kt create mode 100644 store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationDrainTriggerTest.kt create mode 100644 store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationEffectsTest.kt create mode 100644 store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationEventSurfaceTest.kt create mode 100644 store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationEventsTest.kt create mode 100644 store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspectionTest.kt create mode 100644 store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournalContractTest.kt create mode 100644 store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournalTest.kt create mode 100644 store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationKeyResolverTest.kt create mode 100644 store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationOverlayReplayPropertyTest.kt create mode 100644 store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationOverlayTest.kt create mode 100644 store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationPresenceTest.kt create mode 100644 store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocolTest.kt create mode 100644 store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationPruningRegressionTest.kt create mode 100644 store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationRebaseTest.kt create mode 100644 store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationRestartWalkingTest.kt create mode 100644 store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationRetirementTest.kt create mode 100644 store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationSourceOfTruthContractTest.kt create mode 100644 store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilderTest.kt create mode 100644 store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationsTestFixtures.kt create mode 100644 store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationsWalkingSkeletonTest.kt create mode 100644 store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationsWiringSpikeTest.kt create mode 100644 store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutatorSugarTest.kt create mode 100644 store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/docs/MutationDocsSnippetFixtures.kt create mode 100644 store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/docs/MutationOverviewDocsSnippet.kt create mode 100644 store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/docs/MutationServerContractDocsSnippets.kt create mode 100644 store6-mutations/src/jvmTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationApiSurfaceTest.kt create mode 100644 store6-mutations/src/jvmTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournalLincheckTest.kt create mode 100644 store6-paging-androidx/api/android/store6-paging-androidx.api create mode 100644 store6-paging-androidx/api/jvm/store6-paging-androidx.api create mode 100644 store6-paging-androidx/api/store6-paging-androidx.klib.api create mode 100644 store6-paging-androidx/build.gradle.kts create mode 100644 store6-paging-androidx/gradle.properties create mode 100644 store6-paging-androidx/sample/build.gradle.kts create mode 100644 store6-paging-androidx/sample/src/main/kotlin/org/mobilenativefoundation/store6/paging/sample/Main.kt create mode 100644 store6-paging-androidx/src/androidMain/AndroidManifest.xml create mode 100644 store6-paging-androidx/src/commonMain/kotlin/org/mobilenativefoundation/store6/paging/GenerationWatcher.kt create mode 100644 store6-paging-androidx/src/commonMain/kotlin/org/mobilenativefoundation/store6/paging/PagingSourceFactory.kt create mode 100644 store6-paging-androidx/src/commonMain/kotlin/org/mobilenativefoundation/store6/paging/StorePagingBuilder.kt create mode 100644 store6-paging-androidx/src/commonMain/kotlin/org/mobilenativefoundation/store6/paging/StorePagingConfig.kt create mode 100644 store6-paging-androidx/src/commonMain/kotlin/org/mobilenativefoundation/store6/paging/StorePagingSource.kt create mode 100644 store6-paging-androidx/src/commonMain/kotlin/org/mobilenativefoundation/store6/paging/StoreRemoteMediator.kt create mode 100644 store6-paging-androidx/src/commonTest/kotlin/org/mobilenativefoundation/store6/paging/PagingInvalidationTest.kt create mode 100644 store6-paging-androidx/src/commonTest/kotlin/org/mobilenativefoundation/store6/paging/PagingMutationTracerTest.kt create mode 100644 store6-paging-androidx/src/commonTest/kotlin/org/mobilenativefoundation/store6/paging/PagingTestFixtures.kt create mode 100644 store6-paging-androidx/src/commonTest/kotlin/org/mobilenativefoundation/store6/paging/StorePagingSourceTest.kt create mode 100644 store6-paging-androidx/src/commonTest/kotlin/org/mobilenativefoundation/store6/paging/StoreRemoteMediatorTest.kt create mode 100644 store6-quickstart/build.gradle.kts create mode 100644 store6-quickstart/src/main/kotlin/org/mobilenativefoundation/store6/quickstart/Main.kt create mode 100644 store6-realtime/README.md create mode 100644 store6-realtime/api/android/store6-realtime.api create mode 100644 store6-realtime/api/jvm/store6-realtime.api create mode 100644 store6-realtime/api/store6-realtime.klib.api create mode 100644 store6-realtime/build.gradle.kts create mode 100644 store6-realtime/gradle.properties create mode 100644 store6-realtime/sample/build.gradle.kts create mode 100644 store6-realtime/sample/src/main/kotlin/org/mobilenativefoundation/store6/realtime/sample/Main.kt create mode 100644 store6-realtime/src/androidMain/AndroidManifest.xml create mode 100644 store6-realtime/src/commonMain/kotlin/org/mobilenativefoundation/store6/realtime/RealtimeBinding.kt create mode 100644 store6-realtime/src/commonMain/kotlin/org/mobilenativefoundation/store6/realtime/RealtimeBindings.kt create mode 100644 store6-realtime/src/commonMain/kotlin/org/mobilenativefoundation/store6/realtime/RealtimeMessage.kt create mode 100644 store6-realtime/src/commonTest/kotlin/org/mobilenativefoundation/store6/realtime/RealtimeAdoptionTest.kt create mode 100644 store6-realtime/src/commonTest/kotlin/org/mobilenativefoundation/store6/realtime/RealtimeBindingLifecycleTest.kt create mode 100644 store6-realtime/src/commonTest/kotlin/org/mobilenativefoundation/store6/realtime/RealtimeInvalidationTest.kt create mode 100644 store6-realtime/src/commonTest/kotlin/org/mobilenativefoundation/store6/realtime/RealtimeTestFixtures.kt create mode 100644 store6-room/README.md create mode 100644 store6-room/api/android/store6-room.api create mode 100644 store6-room/api/jvm/store6-room.api create mode 100644 store6-room/api/store6-room.klib.api create mode 100644 store6-room/build.gradle.kts create mode 100644 store6-room/gradle.properties create mode 100644 store6-room/sample/build.gradle.kts create mode 100644 store6-room/sample/src/main/kotlin/org/mobilenativefoundation/store6/room/sample/Main.kt create mode 100644 store6-room/src/androidMain/AndroidManifest.xml create mode 100644 store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/RoomBookkeeper.kt create mode 100644 store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/RoomSourceOfTruth.kt create mode 100644 store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/RoomStoreMeta.kt create mode 100644 store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/Store6BookkeeperDao.kt create mode 100644 store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/Store6BookkeepingEntity.kt create mode 100644 store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/Store6RoomSchema.kt create mode 100644 store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/Store6WatermarkEntity.kt create mode 100644 store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomBookkeeperContractTest.kt create mode 100644 store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomBookkeeperTest.kt create mode 100644 store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomSourceOfTruthContractTest.kt create mode 100644 store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomSourceOfTruthReaderSemanticsTest.kt create mode 100644 store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomStoreSubstitutionConformanceTest.kt create mode 100644 store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomTransactionalSourceOfTruthTest.kt create mode 100644 store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/Store6RoomSchemaMigrationTest.kt create mode 100644 store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/TestDatabase.kt create mode 100644 store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/TestKeys.kt create mode 100644 store6-room/src/jvmTest/kotlin/org/mobilenativefoundation/store6/room/TestDatabase.jvm.kt create mode 100644 store6-room/src/nativeTest/kotlin/org/mobilenativefoundation/store6/room/TestDatabase.native.kt create mode 100644 store6-sqldelight/README.md create mode 100644 store6-sqldelight/api/android/store6-sqldelight.api create mode 100644 store6-sqldelight/api/jvm/store6-sqldelight.api create mode 100644 store6-sqldelight/api/store6-sqldelight.klib.api create mode 100644 store6-sqldelight/build.gradle.kts create mode 100644 store6-sqldelight/gradle.properties create mode 100644 store6-sqldelight/sample/build.gradle.kts create mode 100644 store6-sqldelight/sample/src/main/kotlin/org/mobilenativefoundation/store6/sqldelight/sample/Main.kt create mode 100644 store6-sqldelight/sample/src/main/sqldelight/org/mobilenativefoundation/store6/sqldelight/sample/db/User.sq create mode 100644 store6-sqldelight/src/androidMain/AndroidManifest.xml create mode 100644 store6-sqldelight/src/androidMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/ExecutionThread.android.kt create mode 100644 store6-sqldelight/src/androidMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SystemEpochMillis.android.kt create mode 100644 store6-sqldelight/src/androidUnitTest/kotlin/org/mobilenativefoundation/store6/sqldelight/FreshHarness.android.kt create mode 100644 store6-sqldelight/src/appleMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/ExecutionThread.apple.kt create mode 100644 store6-sqldelight/src/appleMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SystemEpochMillis.apple.kt create mode 100644 store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightBookkeeper.kt create mode 100644 store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightSourceOfTruth.kt create mode 100644 store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/DriverAccess.kt create mode 100644 store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/ExecutionThread.kt create mode 100644 store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/MetaSidecar.kt create mode 100644 store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SynchronousTransactions.kt create mode 100644 store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SystemEpochMillis.kt create mode 100644 store6-sqldelight/src/commonSqlTest/kotlin/app/cash/turbine/BorrowedTurbineTimeout.kt create mode 100644 store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/core/BorrowedSuiteSupport.kt create mode 100644 store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightAtomicityTest.kt create mode 100644 store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightBookkeeperContractTest.kt create mode 100644 store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightBookkeeperRestartTest.kt create mode 100644 store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightCrashWindowTest.kt create mode 100644 store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightDurableMaintenanceTest.kt create mode 100644 store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightSchedulerPerturbationRuns.kt create mode 100644 store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightSourceOfTruthContractTest.kt create mode 100644 store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightSubstitutionRuns.kt create mode 100644 store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightTestFixtures.kt create mode 100644 store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlHarness.kt create mode 100644 store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/MetaSidecarTest.kt create mode 100644 store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SynchronousTransactionsTest.kt create mode 100644 store6-sqldelight/src/jsMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/ExecutionThread.js.kt create mode 100644 store6-sqldelight/src/jsMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SystemEpochMillis.js.kt create mode 100644 store6-sqldelight/src/jvmMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/ExecutionThread.jvm.kt create mode 100644 store6-sqldelight/src/jvmMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SystemEpochMillis.jvm.kt create mode 100644 store6-sqldelight/src/jvmTest/kotlin/org/mobilenativefoundation/store6/sqldelight/DriverAccessConcurrencyJvmTest.kt create mode 100644 store6-sqldelight/src/jvmTest/kotlin/org/mobilenativefoundation/store6/sqldelight/FreshHarness.jvm.kt create mode 100644 store6-sqldelight/src/linuxMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/ExecutionThread.linux.kt create mode 100644 store6-sqldelight/src/linuxMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SystemEpochMillis.linux.kt create mode 100644 store6-sqldelight/src/mingwMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/ExecutionThread.mingw.kt create mode 100644 store6-sqldelight/src/mingwMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SystemEpochMillis.mingw.kt create mode 100644 store6-sqldelight/src/nativeSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/FreshHarness.native.kt create mode 100644 store6-sqldelight/src/wasmJsMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/ExecutionThread.wasmJs.kt create mode 100644 store6-sqldelight/src/wasmJsMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SystemEpochMillis.wasmJs.kt create mode 100644 store6-swift-dumps/mutations-objc/build.gradle.kts create mode 100644 store6-swift-dumps/mutations-objc/src/commonMain/kotlin/org/mobilenativefoundation/store/store6/swiftdump/mutationsobjc/SwiftDumpAnchor.kt create mode 100644 store6-swift-dumps/mutations-skie/build.gradle.kts create mode 100644 store6-swift-dumps/mutations-skie/src/commonMain/kotlin/org/mobilenativefoundation/store/store6/swiftdump/mutationsskie/SwiftDumpAnchor.kt create mode 100644 store6-swift-dumps/objc/build.gradle.kts create mode 100644 store6-swift-dumps/objc/src/commonMain/kotlin/org/mobilenativefoundation/store/store6/swiftdump/objc/SwiftDumpAnchor.kt create mode 100644 store6-swift-dumps/skie/build.gradle.kts create mode 100644 store6-swift-dumps/skie/src/commonMain/kotlin/org/mobilenativefoundation/store/store6/swiftdump/skie/SwiftDumpAnchor.kt create mode 100644 store6-testing/api/android/store6-testing.api create mode 100644 store6-testing/api/jvm/store6-testing.api create mode 100644 store6-testing/api/store6-testing.klib.api create mode 100644 store6-testing/build.gradle.kts create mode 100644 store6-testing/gradle.properties create mode 100644 store6-testing/src/androidMain/AndroidManifest.xml create mode 100644 store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/BookkeeperContractKit.kt create mode 100644 store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeBookkeeper.kt create mode 100644 store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeFetcher.kt create mode 100644 store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeSourceOfTruth.kt create mode 100644 store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStore.kt create mode 100644 store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStoreInteraction.kt create mode 100644 store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/SourceOfTruthContractKit.kt create mode 100644 store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/TestStoreMeta.kt create mode 100644 store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/TestStoreResults.kt create mode 100644 store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/TestWallClock.kt create mode 100644 store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeBookkeeperAlgebraTest.kt create mode 100644 store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeBookkeeperContractTest.kt create mode 100644 store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeBookkeeperRestartTest.kt create mode 100644 store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeFetcherIntegrationTest.kt create mode 100644 store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeSourceOfTruthContractTest.kt create mode 100644 store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeStoreConformanceTest.kt create mode 100644 store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/TestStoreResultsTest.kt create mode 100644 store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/TestingKey.kt create mode 100644 store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/UserViewModelSampleTest.kt create mode 100644 store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/docs/GuideTestingSnippets.kt delete mode 100644 tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/FormattingConventionPlugin.kt create mode 100644 tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/Store6Conventions.kt create mode 100644 tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/Store6MultiplatformConventionPlugin.kt create mode 100644 tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/Store6MultiplatformSubsetConventionPlugin.kt create mode 100644 tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/SwiftDumpTasksPlugin.kt delete mode 100644 tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/VersionCatalogExt.kt create mode 100644 tooling/plugins/src/test/kotlin/org/mobilenativefoundation/store/tooling/plugins/SwiftDumpTasksPluginTest.kt diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 000000000..284c5ce8a --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-marketplace.json", + "name": "store6", + "owner": { + "name": "Matt Ramotar", + "email": "matt.ramotar@uber.com" + }, + "description": "Plugins for applications using Store.", + "plugins": [ + { + "name": "store", + "source": "./plugins/store", + "description": "Skills for applications using Store (org.mobilenativefoundation.store), starting with Store 4/5 to Store 6 migration." + } + ] +} diff --git a/.claude/skills/code-documentation/SKILL.md b/.claude/skills/code-documentation/SKILL.md new file mode 100644 index 000000000..08b610942 --- /dev/null +++ b/.claude/skills/code-documentation/SKILL.md @@ -0,0 +1,76 @@ +--- +name: code-documentation +description: Use when creating, revising, auditing, or synchronizing a repository README, package README, API or interface documentation, implementation documentation, docstring, doc comment, or inline comment. +--- + +# Code Documentation + +## Mandatory composition + +Run with `documentation-discipline`. This skill governs evidence, artifact +shape, mutation, and verification. That skill governs every sentence. + +## Surface routing + +Every surface inherits `documentation-discipline`, including its prohibition +on local or private organizational context. + +Repository instructions, complete existing documentation, generators, and +local conventions override these bundled defaults. These playbooks are not +fixed templates; include only material warranted by evidence and the reader +job. + +- Repository README: `references/surfaces/repository-readme.md` +- Package README: `references/surfaces/package-readme.md` +- Interface documentation: + `references/surfaces/interface-documentation.md` +- Implementation documentation: + `references/surfaces/implementation-documentation.md` +- Inline documentation: `references/surfaces/inline-documentation.md` + +## Language routing + +Language adapters are repository fallbacks, and every adapter inherits +`documentation-discipline`. + +- Python: `references/languages/python.md` +- TypeScript and JavaScript: + `references/languages/typescript-javascript.md` +- Java: `references/languages/java.md` +- Kotlin: `references/languages/kotlin.md` +- Rust: `references/languages/rust.md` +- Go: `references/languages/go.md` + +## Modes + +- **Create:** write the smallest coherent artifact for the reader task. +- **Revise:** preserve useful content and change only the material delta. +- **Audit:** remain read-only. For every finding, name the affected file, + section, or symbol; show the evidence; explain reader impact; and give a + concrete remediation. Do not edit files, commit, publish, or update external + systems. + +## Workflow + +1. Orient to repository instructions, docs, manifests, exports, tests, build + configuration, generators, and local conventions. +2. Classify mode, surface, reader, audience, publication boundary, language, + hand-authored or generated ownership, and the requested files, packages, + modules, or repository scope. If the request already establishes these + dimensions, proceed without a mandatory clarification or outline gate. +3. Read + [references/evidence-and-verification.md](references/evidence-and-verification.md). +4. Read only the requested surface references and relevant language adapters. +5. Build the evidence inventory and classify the documentation delta. +6. Create, revise, or audit without expanding authority. +7. Run surface, language, repository, generated-ownership, and source-mutation + checks. +8. Report files, evidence, checks, uncertainty, omissions, and proof strength. + +## Stop conditions + +- Missing evidence: omit or label uncertainty. +- Failed command or example: do not present it as working. +- Generated target without its authority: report the blocked source. +- Executable-token change: stop the affected edit. +- Public disclosure risk: stop and report the boundary. diff --git a/.claude/skills/code-documentation/references/evidence-and-verification.md b/.claude/skills/code-documentation/references/evidence-and-verification.md new file mode 100644 index 000000000..042b50745 --- /dev/null +++ b/.claude/skills/code-documentation/references/evidence-and-verification.md @@ -0,0 +1,102 @@ +# Evidence and Verification + +## Evidence precedence + +Use this order unless repository instructions override it: + +1. Repository instructions and configured generators. +2. Public interfaces, schemas, manifests, configuration, and source. +3. Contract and behavior tests. +4. Current build, release, and deployment configuration. +5. Existing documentation that agrees with the live repository. +6. Explicitly labeled inference or uncertainty. + +Tests show observed behavior. They do not automatically prove intent, public +support, or historical rationale. + +Local or private organizational context is not publication content or +provenance. Under the mandatory `documentation-discipline` rule, do not expose +or cite private Linear or other tracker identifiers, internal project names or +labels, internal initiative or rollout state, landing or ship status, approval +state, team shorthand, internal owners or channels, or provisional governance +labels. Accessibility does not grant publication authority. Make documentation +self-contained and durable. Retain only independently verified technical +behavior and contracts. + +## Finding classes + +Classify each candidate finding before reporting or changing it: + +| Class | Meaning | +| --- | --- | +| Missing | Reader-required documentation is absent. | +| Stale | The documentation no longer matches the live repository. | +| Contradictory | Two claims or a claim and the implementation disagree. | +| Duplicated | Repeated content creates avoidable maintenance or drift risk. | +| Unverifiable | Available evidence cannot establish the claim. | +| Misleading | Individually true wording creates an incorrect reader conclusion. | +| Unnecessary | The content does not help the reader use, change, operate, or reason about the system. | +| Sufficient | The content is accurate, warranted, and complete for the reader task. | + +In audits, prioritize incorrect contracts, unsafe commands, disclosure +problems, and broken onboarding before style. + +## Generated documentation ownership + +Classify each target as hand-authored, generated, or mixed. Authoritative source +inputs govern generated output. Edit those inputs instead of their derived +artifacts. + +For mixed artifacts, preserve repository-defined safe boundaries between +manually authored and generated content. Do not move text across or blur those +boundaries without established repository authority. + +Regenerate only when the generator is available, the action is in scope, and +the resulting diff is reviewable. Otherwise, report the missing prerequisite +and affected artifact. Never hand-patch generated output and call it complete. + +## Documentation-only source mutation + +Authorized mutations are docstrings, doc comments, and ordinary inline +comments. Documentation annotations or metadata are authorized only when +repository policy classifies them as documentation and applicable repository +checks establish no runtime effect. They are otherwise protected. + +Protected content includes executable statements or expressions, control flow, +declarations or signatures, types, imports, runtime-effect configuration, +generated runtime code, and executable formatting changes. Semantic directives +and behavior-bearing comments are protected, not ordinary comment edits. +Language adapters identify their language-specific forms. + +In a dirty worktree, preserve unrelated edits. Separate pre-existing or user +edits from agent-created hunks. Inspect every introduced hunk, use +language-aware tooling when available, and run relevant repository checks. +Passing tests alone is not proof of a documentation-only boundary. Report the +proof level. + +If an executable-token change appears, stop the affected edit. Revert only the +agent-created hunk when that is safe; otherwise, report the unresolved boundary. + +## Verification evidence levels + +- **Mechanically proven:** language-aware tooling establishes that introduced + changes affect only the authorized documentation surface. +- **Repository checks plus hunk review:** relevant checks pass and every + introduced hunk has been inspected, but no mechanical boundary proof is + available. +- **Not established:** tooling, checks, or review cannot establish the claimed + documentation-only boundary. + +Use the strongest level the evidence actually supports. Do not upgrade the +classification because tests pass. + +## Completion report + +Report: + +- reviewed and changed files; +- evidence used for claims; +- commands and results; +- unresolved uncertainty; +- claims omitted for lack of evidence; and +- actual proof strength. diff --git a/.claude/skills/code-documentation/references/languages/go.md b/.claude/skills/code-documentation/references/languages/go.md new file mode 100644 index 000000000..624426136 --- /dev/null +++ b/.claude/skills/code-documentation/references/languages/go.md @@ -0,0 +1,71 @@ +# Go documentation fallback + +## Repository precedence + +Use repository-local format, tooling, generators, and coherent repository +conventions first. When coherent repository conventions conflict with this +adapter, the repository wins. Preserve package comments, exported declaration +comments, and the local lint convention. Treat this adapter as a fallback, not +a house style. + +## Public surface + +Document only intended public declarations and exported names that form the +supported API. Cover parameters, return values, result types, ownership, and +caller-visible contracts without repeating the declaration. + +## Native format + +Use comments in native placement and native syntax for packages and exported +declarations. Begin declaration comments according to repository and Go +tooling convention. + +## Errors and lifecycle + +Document blocking, concurrency ownership, cancellation, errors, resource +lifecycle, and cleanup when callers must coordinate them. Do not invent +goroutine, ordering, or retry guarantees. + +## Examples + +Use runnable `Example...` conventions when the repository uses example tests. +Verify examples with `go test` and keep setup focused on the documented +behavior. + +## Links and cross-references + +Use repository-supported links and cross-references for packages, declarations, +and guides. Resolve every target in `go doc` or the configured documentation +renderer. + +## Generated documentation + +Establish generated ownership before editing generated comments or reference +output. Change authoritative source inputs, run the repository's regeneration +workflow, and inspect the generated diff. + +## Protected directives and semantic comments + +Protected directives and semantic comments are not ordinary documentation +edits. Preserve build tags, `//go:` directives, `//line` and `/*line` line +directives, cgo preambles, cgo `//export` directives, lint directives, the +exact `Deprecated:` doc marker, and runnable Example function bodies. Line +directives alter source positions, `//export` alters cgo exports, and +`Deprecated:` changes tooling deprecation behavior. They are not ordinary +wording edits. Struct tags are runtime-visible metadata, not documentation-only; +treat unknown metadata as protected. + +## Fixture cases + +- **Allowed:** Clarify cancellation ownership on an exported function without + changing its declaration. +- **Disallowed:** Reword a build tag, `//go:` directive, `//line`, `/*line`, + cgo `//export`, exact `Deprecated:` marker, struct tag, or runnable Example + body as documentation. + +## Documentation-only verification + +Run configured documentation lint, `go doc`, `go test`, focused tests, and +`go vet` when the repository uses those commands. Inspect every changed hunk. +If the repository lacks a mechanical token/AST/trivia-equivalence proof, +report the boundary as `not mechanically proven` even if checks pass. diff --git a/.claude/skills/code-documentation/references/languages/java.md b/.claude/skills/code-documentation/references/languages/java.md new file mode 100644 index 000000000..9427bbf94 --- /dev/null +++ b/.claude/skills/code-documentation/references/languages/java.md @@ -0,0 +1,70 @@ +# Java documentation fallback + +## Repository precedence + +Use repository-local format, tooling, generators, and coherent repository +conventions first. When coherent repository conventions conflict with this +adapter, the repository wins. Preserve the local Javadoc convention and +configured tooling. Treat this adapter as a fallback, not a house style. + +## Public surface + +Document only intended public declarations and public and protected members +that form the intended API. Cover parameters, return values, types, type +parameters, and caller-visible contracts without narrating the signature. + +## Native format + +Use Javadoc in native placement and native syntax recognized by the configured +doclint and documentation task. Keep tags attached to the declaration they +describe and follow local tag ordering. + +## Errors and lifecycle + +Document checked exceptions, relevant unchecked exceptions, ownership, +nullability, lifecycle, cleanup, and thread safety when callers must depend on +them. Do not infer guarantees from annotations alone. + +## Examples + +Prefer small repository examples that compile against the intended API. +Verify examples with the configured build or test harness before presenting +them as working. + +## Links and cross-references + +Use Javadoc links and cross-references according to repository convention. +Resolve every type, member, package, and external target under configured +documentation generation. + +## Generated documentation + +Establish generated ownership before editing Javadoc output. Change +authoritative source inputs, run the repository's regeneration workflow, and +review generated pages for missing members or broken links. + +## Protected directives and semantic comments + +Protected directives and semantic comments are not ordinary documentation +edits. Preserve annotations, signatures, suppression controls, and +processor-sensitive comments. Edit a repository-owned documentation annotation +only when repository policy classifies it as documentation and compile, +reflection, and configured generation checks establish no runtime effect; +otherwise it is protected. + +## Fixture cases + +- **Allowed:** Add a missing checked-exception contract to an intended API + method without changing its signature. +- **Disallowed:** Replace a suppression control or processor-sensitive comment + with Javadoc. +- **Conditional:** Change a repository-owned documentation annotation only + when repository policy classifies it as documentation and compile, + reflection, and configured generation checks establish no runtime effect. + +## Documentation-only verification + +Run configured Javadoc and doclint, compile the affected source, and run +focused tests. Inspect generated links and public members. If the repository +lacks a mechanical token/AST/trivia-equivalence proof, report the boundary as +`not mechanically proven` even if checks pass. diff --git a/.claude/skills/code-documentation/references/languages/kotlin.md b/.claude/skills/code-documentation/references/languages/kotlin.md new file mode 100644 index 000000000..318339bb7 --- /dev/null +++ b/.claude/skills/code-documentation/references/languages/kotlin.md @@ -0,0 +1,68 @@ +# Kotlin documentation fallback + +## Repository precedence + +Use repository-local format, tooling, generators, and coherent repository +conventions first. When coherent repository conventions conflict with this +adapter, the repository wins. Preserve the local KDoc convention and +configured Dokka behavior. Treat this adapter as a fallback, not a house style. + +## Public surface + +Document only intended public declarations and respect multiplatform source-set +boundaries. Cover parameters, return values, types, and nullability semantics +beyond the type when they affect callers; do not restate declarations. + +## Native format + +Use KDoc in native placement and native syntax recognized by configured Dokka. +Use declaration links according to local convention and keep documentation +attached to the declaration for the correct source set. + +## Errors and lifecycle + +Describe coroutines, cancellation, flows, collection and completion behavior, +lifecycle, cleanup, and public errors when callers must respond. Do not invent +threading, dispatcher, or exception guarantees. + +## Examples + +Prefer repository examples for the relevant platform and source set. Verify +examples with the configured compilation or test task before presenting them +as portable. + +## Links and cross-references + +Use KDoc declaration links and repository cross-references. Resolve every +target in the applicable source set and generated documentation. + +## Generated documentation + +Establish generated ownership before editing Dokka output. Change authoritative +source inputs, run the repository's regeneration workflow, and inspect output +for platform or source-set omissions. + +## Protected directives and semantic comments + +Protected directives and semantic comments are not ordinary documentation +edits. Preserve annotations, declarations, source-set directives, suppression +controls, and treat unknown annotation effects as protected. Edit +repository-owned documentation metadata only when repository policy classifies +it as documentation and applicable checks establish no runtime effect; +otherwise it is protected. + +## Fixture cases + +- **Allowed:** Clarify flow cancellation behavior on an intended public + declaration without changing its type or source set. +- **Disallowed:** Reword an annotation or source-set directive as KDoc. +- **Conditional:** Change repository-owned documentation metadata only when + repository policy classifies it as documentation and applicable checks + establish no runtime effect. + +## Documentation-only verification + +Run the relevant Dokka task, compilation, configured documentation lint when +present, and focused tests for affected source sets. Inspect platform output. +If the repository lacks a mechanical token/AST/trivia-equivalence proof, +report the boundary as `not mechanically proven` even if checks pass. diff --git a/.claude/skills/code-documentation/references/languages/python.md b/.claude/skills/code-documentation/references/languages/python.md new file mode 100644 index 000000000..edeb751c3 --- /dev/null +++ b/.claude/skills/code-documentation/references/languages/python.md @@ -0,0 +1,74 @@ +# Python documentation fallback + +## Repository precedence + +Use repository-local format, tooling, generators, and coherent repository +conventions first. When coherent repository conventions conflict with this +adapter, the repository wins. Preserve a coherent Google, NumPy, Sphinx, or +local style; use PEP 257 only as the fallback. Treat this adapter as a fallback, +not a house style. + +## Public surface + +Document only intended public declarations at module, class, function, method, +property, and attribute boundaries. Cover parameters, return values, types, +and caller-visible contracts without repeating annotations; use semantic prose +only where annotations do not express the behavior. + +## Native format + +Use docstrings in native placement and native syntax. Docstrings are +runtime-visible through `__doc__`, so preserve placement and inspect consumers. +Treat doctests as executable documentation and run them. + +## Errors and lifecycle + +Describe caller-relevant errors, generators, completion and yielding behavior, +async cancellation and ordering, context managers, cleanup, and resource +ownership. Do not promise exceptions or lifecycle guarantees that source and +tests do not establish. + +## Examples + +Prefer the repository's example style and the smallest case that clarifies the +contract. Verify examples, and run doctests when the repository treats them as +executable. + +## Links and cross-references + +Use the repository's links and cross-references for modules, symbols, and +long-form guides. Resolve every target and avoid inventing import paths or +generated anchors. + +## Generated documentation + +Establish generated ownership before editing. Change authoritative source +inputs and use the repository's regeneration workflow; review generated +outputs for unexpected public-surface changes. + +## Protected directives and semantic comments + +Protected directives and semantic comments are not ordinary documentation +edits. Preserve the shebang, encoding cookie, type comments, `# noqa`, +`# type: ignore`, `# pyright:`, formatter controls, coverage directives, and +other tool-facing comments. Edit repository-owned documentation metadata only +when repository policy classifies it as documentation and applicable checks +establish no runtime effect; otherwise it is protected. + +## Fixture cases + +- **Allowed:** Clarify an intended public method's ownership rule without + changing its signature or annotations. +- **Disallowed:** Reword `# type: ignore` as prose or move an executable + doctest without separately scoped authorization. +- **Conditional:** Change repository-owned documentation metadata only when + repository policy classifies it as documentation and applicable checks + establish no runtime effect. + +## Documentation-only verification + +Run the configured docstring lint, `python3 -m compileall`, doctests, and +focused tests. Use AST/token comparison only if repository tooling supports +it, and inspect every changed hunk. If the repository lacks a mechanical +token/AST/trivia-equivalence proof, report the boundary as +`not mechanically proven` even if checks pass. diff --git a/.claude/skills/code-documentation/references/languages/rust.md b/.claude/skills/code-documentation/references/languages/rust.md new file mode 100644 index 000000000..963953209 --- /dev/null +++ b/.claude/skills/code-documentation/references/languages/rust.md @@ -0,0 +1,75 @@ +# Rust documentation fallback + +## Repository precedence + +Use repository-local format, tooling, generators, and coherent repository +conventions first. When coherent repository conventions conflict with this +adapter, the repository wins. Preserve the local rustdoc convention and lint +configuration. Treat this adapter as a fallback, not a house style. + +## Public surface + +Document only intended public declarations. Cover parameters, return values, +types, lifetimes, ownership, and caller-visible invariants without narrating +the signature. + +## Native format + +Use rustdoc in native placement and native syntax. `///`, `//!`, `/**`, and +`/*!` become documentation attributes. When an item is passed to declarative +or procedural macros, those attributes are macro input and may affect generated +code. Doctests compile or run; preserve item versus module placement and test +behavior. + +## Errors and lifecycle + +Document panics, errors, safety requirements, ownership lifecycle, feature +gates, platform behavior, and intra-doc links when users must account for them. +Keep claims conditional where `cfg` or features alter behavior. + +## Examples + +Prefer repository examples and the local convention for hidden setup. Verify +examples through rustdoc doctests and show only the code readers need. + +## Links and cross-references + +Use rustdoc intra-doc links and repository cross-references. Resolve every +target under the relevant features and avoid guessed item paths. + +## Generated documentation + +Establish generated ownership before editing rustdoc output. Change +authoritative source inputs, run the repository's regeneration workflow, and +inspect feature-dependent output. + +## Protected directives and semantic comments + +Protected directives and semantic comments are not ordinary documentation +edits. Preserve non-doc attributes, lint controls, feature gates, and macro +input. Treat doc comments and explicit `#[doc = "..."]` identically on macro +input: edit them only when repository convention classifies both forms as +documentation and macro-aware compile, expansion, and generated-output checks +establish no generated-code or runtime effect; otherwise both forms are +protected. Ordinary Rust doc comments remain editable when the macro-sensitive +condition is absent and documentation-only proof is established. + +## Fixture cases + +- **Allowed:** Clarify an ordinary public function's doc comment when the + macro-sensitive condition is absent and documentation-only proof exists. +- **Disallowed:** Rewrite a lint attribute or macro-visible doc attribute + without macro-aware generated-code checks. +- **Conditional:** Change doc comments and explicit `#[doc = "..."]` on macro + input only when repository convention classifies both as documentation and + macro-aware compile, expansion, and generated-output checks establish no + generated-code or runtime effect. + +## Documentation-only verification + +Run configured lints, `cargo doc --no-deps`, `cargo test --doc`, and focused +tests under relevant features. For macro input, run configured macro-aware +compile or expansion checks and inspect generated output for code or runtime +changes. Inspect generated links and warnings. If the repository lacks a +mechanical token/AST/trivia-equivalence proof, report the boundary as +`not mechanically proven` even if checks pass. diff --git a/.claude/skills/code-documentation/references/languages/typescript-javascript.md b/.claude/skills/code-documentation/references/languages/typescript-javascript.md new file mode 100644 index 000000000..e927f0147 --- /dev/null +++ b/.claude/skills/code-documentation/references/languages/typescript-javascript.md @@ -0,0 +1,77 @@ +# TypeScript and JavaScript documentation fallback + +## Repository precedence + +Use repository-local format, tooling, generators, and coherent repository +conventions first. When coherent repository conventions conflict with this +adapter, the repository wins. Preserve the local TSDoc, JSDoc, TypeDoc, or +other local convention. Treat this adapter as a fallback, not a house style. + +## Public surface + +Document only intended public declarations and intended exports; verify barrel +ownership before treating a symbol as public. Cover parameters, return values, +types, generics, and caller-visible behavior, but do not duplicate signature +types in prose. + +## Native format + +Use doc comments in native placement and native syntax for the repository's +JavaScript or TypeScript toolchain. Keep comments attached to the declaration +the configured parser and documentation generator actually consume. + +## Errors and lifecycle + +Describe async resolution, rejection, cancellation, thrown errors, lifecycle, +side effects, and resource ownership when callers must act on them. Do not +invent guarantees about scheduling or cleanup. + +## Examples + +Prefer repository examples that use supported imports and configuration. +Verify examples with the configured runner or typechecker when they are +executable. + +## Links and cross-references + +Use configured links and cross-references for exports, symbols, and guides. +Resolve every target in the generated documentation and avoid guessed barrel +paths or anchors. + +## Generated documentation + +Establish generated ownership before editing declaration output or documentation +generator output. Change authoritative source inputs, run the repository's +regeneration workflow, and inspect declarations for contract drift. + +## Protected directives and semantic comments + +Protected directives and semantic comments are not ordinary documentation +edits. Preserve triple-slash references, `@ts-check`, `@ts-ignore`, +`@ts-expect-error`, ESLint and formatter controls, bundler magic comments, +source-map comments, and other tool pragmas. Treat type-bearing JSDoc as +protected: `@type`, typed `@param`, `@returns`, `@template`, `@typedef`, and +similar type or contract tags can affect checking, emitted declarations, or +public contracts. Edit prose-only JSDoc metadata only when repository policy +classifies it as repository-owned documentation and declaration generation and +applicable checks establish no emitted type, signature, or contract change and +no runtime effect; otherwise it is protected. + +## Fixture cases + +- **Allowed:** Clarify prose about an intended export's resource ownership + without changing type-bearing JSDoc or its signature. +- **Disallowed:** Change `@type`, typed `@param`, `@returns`, `@template`, + `@typedef`, `@ts-expect-error`, or a bundler magic comment as ordinary prose. +- **Conditional:** Change prose-only JSDoc metadata only when repository policy + classifies it as repository-owned documentation and declaration generation + and applicable checks establish no emitted type, signature, or contract + change and no runtime effect. + +## Documentation-only verification + +Run configured lint, typecheck, declaration generation, TypeDoc or other doc +generation, and focused tests. Use parser/trivia comparison when available, +then inspect the emitted declaration and documentation diff. If the repository +lacks a mechanical token/AST/trivia-equivalence proof, report the boundary as +`not mechanically proven` even if checks pass. diff --git a/.claude/skills/code-documentation/references/surfaces/implementation-documentation.md b/.claude/skills/code-documentation/references/surfaces/implementation-documentation.md new file mode 100644 index 000000000..bdcaae0a6 --- /dev/null +++ b/.claude/skills/code-documentation/references/surfaces/implementation-documentation.md @@ -0,0 +1,40 @@ +# Implementation Documentation + +## Reader job + +Help a maintainer or operator change or operate internals safely, with enough +context to predict consequences. + +## Inspect + +Inspect source, tests, configuration, operational entry points, generated +ownership, and repository-public design records. Identify the actual component +boundary and the evidence available for the artifact's publication boundary. + +## Include when warranted + +- Ownership and boundaries between components. +- Data, event, or request flow through relevant entry points. +- Invariants and state transitions that changes must preserve. +- Concurrency and consistency behavior. +- Failure and recovery paths. +- Extension points and change hazards. +- Operational entry points needed to diagnose or run the system. +- Operational constraints that bound safe change or operation. +- Troubleshooting entry points that lead from a symptom to evidence. +- Evidenced rationale that explains a durable constraint or tradeoff. + +## Exclude + +- Repeated public reference that belongs in interface documentation. +- Invented or speculative history. +- Unsupported broad architecture claims. +- Private operational or organizational context in public artifacts. +- Detail that does not help a maintainer or operator make a safe decision. + +## Verification + +Trace flow, invariants, failure behavior, and rationale to source, tests, +configuration, or publication-safe operational evidence. Exercise relevant +checks or entry points when authorized. Distinguish mechanically verified +behavior from review-backed explanation and unresolved uncertainty. diff --git a/.claude/skills/code-documentation/references/surfaces/inline-documentation.md b/.claude/skills/code-documentation/references/surfaces/inline-documentation.md new file mode 100644 index 000000000..0e1d2b573 --- /dev/null +++ b/.claude/skills/code-documentation/references/surfaces/inline-documentation.md @@ -0,0 +1,54 @@ +# Inline Documentation + +## Reader job + +Help a reader understand a non-obvious local constraint without leaving the +code or mistaking narration for a contract. + +## Inspect + +Read the local code and adjacent tests around the proposed edit. Identify +existing comments, repository conventions, protected semantic directives, and +the authorized hunk scope before changing text. + +## Include when warranted + +Use inline documentation only when clearer code cannot express: + +- Non-obvious rationale for a durable local choice. +- An invariant or precondition that the surrounding code must preserve. +- A compatibility trap that makes the natural-looking change unsafe. +- A unit, domain, time, or coordinate convention needed to interpret a value. +- A security or privacy boundary enforced at this location. +- Intentionally surprising behavior that is nevertheless correct. +- An algorithmic choice whose material tradeoff matters to future changes. +- An external constraint imposed by a protocol, platform, or dependency. +- Preserve complete current comments when they remain accurate and useful; + revise only the evidenced deficiency. +- A TODO must be self-contained and name a concrete technical completion + condition. +- A TODO must not rely on or cite private Linear or tracker IDs, an internal + owner or channel, project or rollout status, landing or approval status, or + team shorthand. +- A repository-public durable issue URL may accompany the TODO only when + repository convention requires it, but it must never replace the completion + condition. +- This TODO rule overrides any older issue- or owner-only convention. + +## Exclude + +- Next-line narration that predicts the immediately following statement. +- Name narration that restates an identifier. +- Control-flow narration already clear from the code. +- Speculative narration about future behavior or unsupported intent. +- Decorative narration that adds tone without technical value. +- A comment where a clearer name or extracted function would make the same + fact evident. + +## Verification + +Re-read the local code and adjacent tests after editing. Confirm protected +semantic directives are byte-for-byte intact, complete current comments remain +complete, and the hunk scope contains only authorized documentation changes. +Run applicable repository checks, then inspect the diff for accidental +executable or formatting changes. diff --git a/.claude/skills/code-documentation/references/surfaces/interface-documentation.md b/.claude/skills/code-documentation/references/surfaces/interface-documentation.md new file mode 100644 index 000000000..138f8d511 --- /dev/null +++ b/.claude/skills/code-documentation/references/surfaces/interface-documentation.md @@ -0,0 +1,41 @@ +# Interface Documentation + +## Reader job + +Help a caller use a public symbol or external contract correctly without +depending on private implementation behavior. + +## Inspect + +Inspect intended public status, authoritative public declarations, types, +generated ownership, tests, examples, compatibility policy, and deprecation +policy. Separate what the caller can observe and rely on from what the +implementation merely does today. + +## Include when warranted + +Document only caller-visible contracts: + +- Purpose beyond the obvious name. +- Inputs and outputs, including units, defaults, and nullability. +- Errors, exceptions, panic behavior, and failure results callers must handle. +- Side effects, resource ownership, and lifecycle. +- Ordering, concurrency, and idempotency. +- Compatibility and deprecation. +- Examples that clarify correct use or a consequential edge case. + +## Exclude + +- Private internals. +- Implementation choices that callers cannot rely on. +- Guessed guarantees or precision not established by evidence. +- Signature narration that repeats names and types without adding use + semantics. +- Documenting every visible symbol without checking intended public status. +- Any wording that blurs the caller-versus-implementation boundary. + +## Verification + +Compare the documentation with public declarations, tests, and examples. +Exercise examples when tooling permits. Check every caller-visible guarantee +against authoritative behavior, and label or omit anything not established. diff --git a/.claude/skills/code-documentation/references/surfaces/package-readme.md b/.claude/skills/code-documentation/references/surfaces/package-readme.md new file mode 100644 index 000000000..aa304fcb5 --- /dev/null +++ b/.claude/skills/code-documentation/references/surfaces/package-readme.md @@ -0,0 +1,42 @@ +# Package README + +## Reader job + +Help a reader install and use one distributable package without confusing the +package contract with the whole repository. + +## Inspect + +Inspect the existing README before writing. Check package metadata, release +metadata, authoritative build metadata, shipped artifacts, supported runtimes, +public exports, tests, examples, configuration, failure behavior, migrations, +and generated documentation ownership. Preserve it unchanged when it is +already sufficient for the reader job; revise only an evidenced gap. + +## Include when warranted + +- The package promise and package boundary. +- Authoritative compatibility derived from package and build metadata. +- A verified install command for the actual distribution channel. +- A minimal example that reaches the package's first useful result. +- Public entry points and the path to deeper interface documentation. +- Configuration and failure behavior that callers must handle. +- Migration links for supported upgrade paths. +- A link back to repository-level contribution material rather than duplicate + it. + +## Exclude + +- Repository-wide contribution material owned by the repository README or + contributor guide. +- Irrelevant internals that do not affect package users. +- An invented support matrix or compatibility inferred from convention. +- Copied generated API reference that will drift from its authority. + +## Verification + +Run the install command against the intended package artifact and run the +minimal example in the supported environment. Check compatibility against +authoritative metadata, public entry points against the shipped package, and +links against real current targets. Qualify any command or behavior that was +not mechanically exercised. diff --git a/.claude/skills/code-documentation/references/surfaces/repository-readme.md b/.claude/skills/code-documentation/references/surfaces/repository-readme.md new file mode 100644 index 000000000..df69e16f8 --- /dev/null +++ b/.claude/skills/code-documentation/references/surfaces/repository-readme.md @@ -0,0 +1,45 @@ +# Repository README + +## Reader job + +Help a reader understand the project and reach a first successful action +without guessing where the project's responsibility ends. + +## Inspect + +Inspect the existing README before writing. Check repository instructions, +manifests, the top-level layout, executable setup and test configuration, +documentation ownership, and existing support, security, and license files. +Preserve it unchanged when it is already sufficient for the reader job; revise +only an evidenced gap. + +## Include when warranted + +- The project purpose and project boundary in concrete terms. +- Evidenced status, limitations, and maturity that affect whether or how to use + the project. +- A verified quick start that reaches a meaningful first result. +- A useful repository map limited to entry points a reader needs. +- The development and test path, with repository-supported commands. +- Conditional links to deeper package, architecture, and operations + documentation when those targets exist and advance the reader's next task. +- Real support, security, and license links to existing repository-public + targets. + +## Exclude + +- Exhaustive API reference better owned by generated or interface + documentation. +- Invented badges, maturity, or support claims. +- Duplicate package READMEs or repeated package-level install, configuration, + and API detail. +- Cataloging every possible contributor workflow instead of routing to the + repository's maintained contribution guidance. +- Generic sections that do not advance the reader job. + +## Verification + +Run the quick start in its documented context. Run development and test +commands that the README presents as usable. Resolve each support, security, +and license link to its real target. Trace status claims to current repository +evidence, and narrow or qualify anything not established. diff --git a/.claude/skills/documentation-discipline/SKILL.md b/.claude/skills/documentation-discipline/SKILL.md new file mode 100644 index 000000000..b1a3ad42b --- /dev/null +++ b/.claude/skills/documentation-discipline/SKILL.md @@ -0,0 +1,50 @@ +--- +name: documentation-discipline +description: Use when the user wants to create, revise, or audit READMEs, interface or implementation documentation, docstrings, doc comments, or inline comments, or wants focused wording-only work inside an already selected technical-documentation scope. +--- + +# Documentation Discipline + +## Master test + +Keep an element only when the reader needs it to use, change, operate, or +reason about the documented system correctly. + +## Workflow + +1. Identify the reader, task, publication boundary, and protected technical + content. +2. Read [references/discipline-rules.md](references/discipline-rules.md) + completely. +3. Preserve exact contracts before changing prose. +4. Make the smallest complete clarity change. +5. Run the three-pass review. + +## Rules that never defer + +- Use exact technical terms and define them plainly on first use. +- Treat concision as absence of waste, not absence of needed contract detail. +- State confirmed facts, label uncertainty, and omit unsupported claims. +- Preserve identifiers, signatures, commands, paths, URLs, versions, + measurements, schemas, errors, and behavioral guarantees. +- Cut throat-clearing, hype, vague benefits, decorative language, obvious code + narration, and invented precision. +- Follow repository conventions before bundled defaults. + +## Protected technical content + +A style pass cannot alter code blocks, commands, inline code, identifiers, +signatures, paths, URLs, versions, numbers, units, schema fields, error names, +compatibility statements, behavioral guarantees, evidence classifications, +semantic directives, behavior-bearing comments, or tool/runtime configuration. +Comment syntax does not make behavior-bearing content editable. + +## Standalone boundary + +When invoked alone, audit or revise wording only inside the already selected +technical-documentation scope. Do not expand the artifact, invent missing +sections, select a new documentation surface, or change executable code. + +## Three-pass review + +Run separate passes for accuracy, warrant, and reader utility. diff --git a/.claude/skills/documentation-discipline/references/discipline-rules.md b/.claude/skills/documentation-discipline/references/discipline-rules.md new file mode 100644 index 000000000..e87851960 --- /dev/null +++ b/.claude/skills/documentation-discipline/references/discipline-rules.md @@ -0,0 +1,119 @@ +# Documentation Discipline Rules + +## Universal documentation discipline + +Apply this master test to every element: keep it only when the reader needs it +to use, change, operate, or reason about the documented system correctly. + +- Use exact technical terms. Define an unfamiliar term plainly on first use. +- Prefer short, direct sentences, but vary their length enough to avoid a + monotonous, clipped rhythm. +- Treat concision as the absence of waste, not the omission of necessary + contract detail. +- Confirm facts before stating them. Label uncertainty and omit unsupported + claims. +- Prefer periods in prose. Avoid semicolons and decorative em dashes. This + preference does not alter code syntax, schemas, command examples, or + punctuation required by the documented language. +- Preserve complete existing documentation. Revise only what the requested + outcome requires. + +The universal qualities are readability, explicitness, evidence, exact terms, +warranted detail, and the absence of performance or filler. Repository +conventions take precedence over bundled defaults. + +### Standalone boundary + +When this skill is used alone, it may audit or revise wording only inside an +already selected documentation scope. It must not expand the artifact, invent +sections, select a documentation surface, edit executable code, introduce a +repository mutation workflow, or add test-driven-development behavior. + +## Protected technical content + +Preserve identifiers, signatures, commands, paths, URLs, versions, +measurements, code spans, error names, schemas, compatibility statements, +evidence classifications, and behavioral guarantees. + +Semantic directives and behavior-bearing comments remain protected even when +they are syntactically comments. Representative examples include compiler and +linter suppressions, build constraints and tags, bundler directives, generator +markers, doctest directives, and tool/runtime configuration. Comment syntax +does not establish a safe documentation-only boundary. + +| Protected content | Allowed edit | Forbidden edit | +| --- | --- | --- | +| Identifiers, paths, URLs, versions, measurements, numbers, units, and error names | Improve the prose around an exact token. | Rename, normalize, update, round, or substitute the token. | +| Signatures, commands, code blocks, inline code spans, and schemas | Clarify the introduction, caption, or explanation outside the protected content. | Change syntax, arguments, defaults, types, fields, values, ordering with meaning, or executable behavior. | +| Compatibility statements and behavioral guarantees | Explain an unchanged boundary or guarantee more plainly. | Broaden, narrow, strengthen, weaken, or invent a contract. | +| Evidence classifications | Clarify what the existing classification means. | Present an inference as confirmed, remove uncertainty, or invent support. | +| Semantic directives, behavior-bearing comments, and tool/runtime configuration | Improve an explanation outside the protected content. | Add, remove, reorder, or edit compiler or linter suppressions, build constraints or tags, bundler directives, generator markers, doctest directives, or configuration. | + +A style pass may improve surrounding prose, but it cannot silently change a +protected token or contract. If the requested outcome requires such a change, +stop and report the required technical correction as out of scope. Require +separately scoped authorization. This skill must not perform or verify that +technical change. + +## Personal voice boundary + +Personal narrative moves are not universal documentation qualities. They +include first-person ownership, a contrarian thesis, a concrete cold open, a +load-bearing analogy, a strategy arc, and an earned image or stakes close. Only +an explicit `writing-voice` pass may add them, and only after the factual +content is established. + +Do not apply personal voice to reference tables, schemas, code examples, or +normative API or interface behavior. In particular, do not insert ownership +claims or slogans into normative reference text, such as claiming that an API +should be boring or that the writer owns the standard. Reject language such as +"The API should be boring: reliable contracts beat launch copy. I own that +standard here." + +## Documentation anti-patterns + +Remove throat-clearing, hype, empty uplift, vague benefits, buzzwords, +decorative analogies, unsupported or fake precision, speculative intent, +signature imitation, and obvious narration of syntax, names, or control flow. + +Explain only a non-obvious purpose, contract, rationale, invariant, risk, unit, +compatibility boundary, or operational consequence. A request, approval, or +authority instruction does not make obvious narration useful. Reject comments +that merely translate the adjacent code into prose, even when a principal +engineer, senior reviewer, or task owner explicitly asks for more commentary. + +Do not include local or private organizational context in code documentation. +This includes private Linear or other tracker issue IDs or URLs, internal +initiative or rollout names, landing or ship status, approval state, team +shorthand, internal owners or channels, provisional governance labels, and +internal governance labels. This is a universal anti-pattern, not an optional +privacy caveat. + +Make technical wording self-contained and durable. Retain independently +verified technical behavior and contracts. Remove process provenance, +organizational state, and framing based on internal issues, landing, approval, +ownership or channels, or provisional governance. + +Examples to reject include: + +- "Convert the provided string into a numeric port value." +- "Report the invalid original input." +- "Return the validated port number." +- "Start with additive identity." +- "Incorporate current value." +- "Return computed sum." + +Each example repeats visible syntax, a name, or direct control flow without +adding a contract or reason the reader needs. + +## Three-pass review + +1. **Accuracy and protected content.** Compare the result with the source and + verify that every protected token, contract, classification, and behavior is + unchanged. +2. **Warrant and anti-slop.** Remove unsupported claims, fake precision, + performance, filler, and obvious narration. Keep only details supported by + evidence or clearly labeled uncertainty. +3. **Reader task and completeness.** Confirm that the intended reader can + complete the task and reason about the relevant system without missing + prerequisites, boundaries, units, risks, or operational consequences. diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 9d3c613e9..000000000 --- a/.editorconfig +++ /dev/null @@ -1,4 +0,0 @@ -root = true - -[*.{kt,kts}] -ktlint_standard_import-ordering = disabled \ No newline at end of file diff --git a/.github/docs-sync-sources.txt b/.github/docs-sync-sources.txt new file mode 100644 index 000000000..8600d6d62 --- /dev/null +++ b/.github/docs-sync-sources.txt @@ -0,0 +1,11 @@ +CONTRIBUTING.md +ROADMAP.md +STABILITY.md +docs/store6/important-defaults.md +docs/store6/invalidate-vs-clear.md +docs/store6/key-design.md +docs/store6/quickstart.md +llms.txt +store6-compose/README.md +store6-room/README.md +store6-sqldelight/README.md diff --git a/.github/workflows/KMMBridge-Release.yml b/.github/workflows/KMMBridge-Release.yml deleted file mode 100644 index d1a9a9b9e..000000000 --- a/.github/workflows/KMMBridge-Release.yml +++ /dev/null @@ -1,12 +0,0 @@ -# Publish the release XCFramework to a GitHub Release. -# Debug XCFrameworks are built locally on demand via `./gradlew :spmDevBuild`. -name: KMMBridge-Publish -on: - workflow_dispatch: - -jobs: - call-publish: - permissions: - contents: write - packages: write - uses: ./.github/workflows/create_swift_package.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9cd3b5a91..6d94df9c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,21 +2,29 @@ name: CI on: push: - branches: [ main ] + branches: [ main, store6 ] pull_request: - branches: [ main ] + branches: [ main, store6 ] + +permissions: + contents: read jobs: build-and-test: runs-on: ubuntu-latest - timeout-minutes: 30 + # The store6-mutations Lincheck budget measured ~58-59m locally and 2h40m-3h13m hosted + # at the current suite (and once 9h23m locally at an earlier revision). The default + # jvmTest excludes it via the build-gated filter in store6-mutations/build.gradle.kts + # (census-guarded below); the scheduled "Store6 full mutations JVM suite" workflow runs + # the full suite daily. + timeout-minutes: 120 strategy: fail-fast: false matrix: api-level: [ 29 ] steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v4 with: # PR builds (including forks) check out the PR head from its source repo; # push builds fall back to the pushed ref on this repo. Without the @@ -31,7 +39,7 @@ jobs: persist-credentials: false - name: Set up JDK 17 - uses: actions/setup-java@v5 + uses: actions/setup-java@v4 with: distribution: 'zulu' java-version: '17' @@ -42,22 +50,28 @@ jobs: - name: Grant execute permission for Gradlew run: chmod +x gradlew - - name: Build and Test with Coverage - run: ./gradlew clean build koverXmlReport --stacktrace --continue + - name: Build and Test + run: ./gradlew clean build --stacktrace + + - name: Census — default jvmTest must execute exactly the non-Lincheck suites + shell: bash + run: | + set -euo pipefail + expected=$(( $(find store6-mutations/src/commonTest store6-mutations/src/jvmTest -name '*Test.kt' | wc -l | tr -d ' ') - 1 )) + executed=$(find store6-mutations/build/test-results/jvmTest -name 'TEST-*.xml' | wc -l | tr -d ' ') + echo "expected=${expected} executed=${executed}" + [ "${expected}" -eq "${executed}" ] - - name: Upload Coverage to Codecov - # Secrets (including CODECOV_TOKEN) are not exposed to fork PRs, so the - # upload would fail under fail_ci_if_error. Skip it for forks; coverage is - # still uploaded and enforced for same-repo PRs and pushes to main. - if: ${{ github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request' }} - uses: codecov/codecov-action@v6 + - name: Upload test reports + if: ${{ failure() }} + uses: actions/upload-artifact@v4 with: - token: ${{ secrets.CODECOV_TOKEN }} - files: build/reports/kover/coverage.xml - flags: unittests - name: codecov-umbrella - fail_ci_if_error: true - verbose: true + name: test-reports-root-${{ github.run_id }}-${{ github.run_attempt }} + path: | + **/build/test-results/**/*.xml + **/build/reports/tests/** + if-no-files-found: warn + retention-days: 7 publish: if: github.event_name == 'push' && github.ref == 'refs/heads/main' && github.repository == 'MobileNativeFoundation/Store' @@ -65,10 +79,12 @@ jobs: needs: build-and-test steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up JDK 17 - uses: actions/setup-java@v5 + uses: actions/setup-java@v4 with: distribution: 'zulu' java-version: '17' @@ -81,7 +97,7 @@ jobs: - name: Retrieve Version run: | - echo "VERSION_NAME=$(grep -E '^store[[:space:]]*=' gradle/libs.versions.toml | head -1 | cut -d'"' -f2)" >> $GITHUB_ENV + echo "VERSION_NAME=$(grep -w 'VERSION_NAME' gradle.properties | cut -d'=' -f2)" >> $GITHUB_ENV - name: Publish to Maven Central (Central Portal) env: @@ -94,4 +110,4 @@ jobs: ./gradlew publishToMavenCentral else ./gradlew publishAndReleaseToMavenCentral - fi \ No newline at end of file + fi diff --git a/.github/workflows/create_swift_package.yml b/.github/workflows/create_swift_package.yml deleted file mode 100644 index 14f99e9e3..000000000 --- a/.github/workflows/create_swift_package.yml +++ /dev/null @@ -1,67 +0,0 @@ -# Based on: https://github.com/touchlab/KMMBridgeSPMQuickStart/blob/main/.github/workflows/Base-Publish.yml -# Publishes the release XCFrameworks to a GitHub Release. -# For debugging Kotlin from Xcode, build a debug XCFramework locally with -# `./gradlew :spmDevBuild` instead. -name: Base-Publish - -on: - workflow_call: - -permissions: - contents: write - packages: write - -jobs: - kmmbridgepublish: - concurrency: "kmmbridgepublish-${{ github.repository }}" - runs-on: macos-latest - steps: - - name: Checkout the repo with tags - uses: actions/checkout@v6 - with: - fetch-depth: 0 - fetch-tags: true - - - name: Retrieve Version - id: versionPropertyValue - run: | - VERSION=$(grep -E '^store[[:space:]]*=' gradle/libs.versions.toml | head -1 | cut -d'"' -f2) - echo "propVal=$VERSION" >> $GITHUB_OUTPUT - - - name: Set up JDK 17 - uses: actions/setup-java@v5 - with: - distribution: 'zulu' - java-version: '17' - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 - - - name: Grant execute permission for Gradlew - run: chmod +x gradlew - - - name: Create or Find Artifact Release - id: devrelease - uses: softprops/action-gh-release@v2 - with: - token: ${{ secrets.GITHUB_TOKEN }} - tag_name: "${{ steps.versionPropertyValue.outputs.propVal }}" - - - name: Build and Publish - run: | - ./gradlew kmmBridgePublish \ - -PNATIVE_BUILD_TYPE=RELEASE \ - -PGITHUB_ARTIFACT_RELEASE_ID=${{ steps.devrelease.outputs.id }} \ - -PGITHUB_PUBLISH_TOKEN=${{ secrets.GITHUB_TOKEN }} \ - -PGITHUB_REPO=${{ github.repository }} \ - -PENABLE_PUBLISHING=true \ - --no-daemon --info --stacktrace - env: - GRADLE_OPTS: -Dkotlin.incremental=false -Dorg.gradle.jvmargs="-Xmx3g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:MaxMetaspaceSize=512m" - - - uses: touchlab/ga-update-release-tag@v1 - id: update-release-tag - with: - commitMessage: "KMP SPM package release for ${{ steps.versionPropertyValue.outputs.propVal }}" - tagMessage: "KMP release version ${{ steps.versionPropertyValue.outputs.propVal }}" - tagVersion: ${{ steps.versionPropertyValue.outputs.propVal }} diff --git a/.github/workflows/store6-benchmarks.yml b/.github/workflows/store6-benchmarks.yml new file mode 100644 index 000000000..d841ce858 --- /dev/null +++ b/.github/workflows/store6-benchmarks.yml @@ -0,0 +1,92 @@ +name: Store6 Benchmarks + +# Non-blocking measurement lane. Report-only: runs the smoke configuration and uploads JSON +# results. NO step asserts against a number — no numeric performance target has been adopted +# (see store6-benchmarks/README.md for the CI boundary). This workflow is deliberately OUTSIDE +# the exact-head-green ready-gate convention, which continues to mean: the Store6 and CI +# workflows green at the head. +on: + workflow_dispatch: + pull_request: + branches: [ main, store6 ] + paths: + - 'store6-benchmarks/**' + - '.github/workflows/store6-benchmarks.yml' + +permissions: + contents: read + +concurrency: + group: store6-benchmarks-${{ github.ref }} + cancel-in-progress: true + +jobs: + benchmarks-smoke: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout the repo + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Grant execute permission for Gradlew + run: chmod +x gradlew + + - name: Run smoke benchmarks (report-only; no thresholds) + run: ./gradlew :store6-benchmarks:smokeBenchmark --stacktrace + + - name: Summarize results + shell: bash + run: | + set -euo pipefail + reports_dir="store6-benchmarks/build/reports/benchmarks" + mapfile -t json_reports < <(find "${reports_dir}" -type f -name '*.json' -print | LC_ALL=C sort) + report_count="${#json_reports[@]}" + if [[ "${report_count}" -ne 1 ]]; then + echo "ERROR: expected exactly one benchmark JSON under ${reports_dir}; found ${report_count}" >&2 + if [[ "${report_count}" -gt 0 ]]; then + printf ' %s\n' "${json_reports[@]}" >&2 + fi + exit 1 + fi + json="${json_reports[0]}" + if ! jq -e ' + type == "array" and + length > 0 and + all(.[]; + type == "object" and + ((.benchmark | type) == "string") and + ((.benchmark | length) > 0) and + ((has("params") | not) or ((.params | type) == "object")) and + ((.primaryMetric | type) == "object") and + ((.primaryMetric.score | type) == "number") and + ((.primaryMetric.scoreUnit | type) == "string") and + ((.primaryMetric.scoreUnit | length) > 0) + ) + ' "${json}" >/dev/null; then + echo "ERROR: benchmark JSON failed structural validation: ${json}" >&2 + exit 1 + fi + echo "Results from ${json} (smoke-grade numbers; hosted-runner noise applies — see store6-benchmarks/README.md):" + jq -r '.[] | [.benchmark, + ((.params // {}) | to_entries | map("\(.key)=\(.value)") | join(",")), + (.primaryMetric.score | tostring), + .primaryMetric.scoreUnit] + | @tsv' "${json}" | column -t -s $'\t' + + - name: Upload benchmark results + uses: actions/upload-artifact@v4 + with: + name: store6-benchmarks-smoke-${{ github.run_id }} + path: store6-benchmarks/build/reports/benchmarks/ + if-no-files-found: error diff --git a/.github/workflows/store6-full-jvm.yml b/.github/workflows/store6-full-jvm.yml new file mode 100644 index 000000000..22a7145f8 --- /dev/null +++ b/.github/workflows/store6-full-jvm.yml @@ -0,0 +1,71 @@ +name: Store6 full mutations JVM suite + +on: + schedule: + - cron: '17 5 * * *' + workflow_dispatch: {} + +permissions: + contents: read + issues: write + +jobs: + full-mutations-jvm: + runs-on: ubuntu-latest + timeout-minutes: 360 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Grant execute permission for Gradlew + run: chmod +x gradlew + + - name: Run the FULL mutations JVM suite (Lincheck included) + run: ./gradlew :store6-mutations:jvmTest -Pstore6.fullJvmSuite --stacktrace + + - name: Census — no suite may be lost + shell: bash + run: | + set -euo pipefail + expected=$(find store6-mutations/src/commonTest store6-mutations/src/jvmTest -name '*Test.kt' | wc -l | tr -d ' ') + executed=$(find store6-mutations/build/test-results/jvmTest -name 'TEST-*.xml' | wc -l | tr -d ' ') + echo "expected=${expected} executed=${executed}" + [ "${expected}" -eq "${executed}" ] + + - name: Upload test results + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: full-jvm-results-${{ github.run_id }}-${{ github.run_attempt }} + path: | + store6-mutations/build/test-results/**/*.xml + store6-mutations/build/reports/tests/** + if-no-files-found: warn + retention-days: 14 + + - name: File the classification duty on failure + if: ${{ failure() }} + env: + GH_TOKEN: ${{ github.token }} + run: | + gh issue list -R "${GITHUB_REPOSITORY}" --state open \ + --search "Scheduled full mutations JVM suite red in:title" --json number \ + --jq '.[0].number' > /tmp/existing || true + body="Scheduled full-suite run ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID} failed. Classify per docs/v6 conduct (017 register / 031 job-cap class / novel). Never rerun for evidence." + if [ -s /tmp/existing ] && [ "$(cat /tmp/existing)" != "null" ] && [ -n "$(cat /tmp/existing)" ]; then + gh issue comment "$(cat /tmp/existing)" -R "${GITHUB_REPOSITORY}" --body "${body}" + else + gh issue create -R "${GITHUB_REPOSITORY}" \ + --title "Scheduled full mutations JVM suite red (${GITHUB_RUN_ID})" --body "${body}" + fi diff --git a/.github/workflows/store6.yml b/.github/workflows/store6.yml new file mode 100644 index 000000000..59c5b938b --- /dev/null +++ b/.github/workflows/store6.yml @@ -0,0 +1,801 @@ +name: Store6 + +on: + push: + branches: [ main, store6 ] + pull_request: + branches: [ main, store6 ] + types: [ opened, synchronize, reopened, labeled, unlabeled ] + +permissions: + contents: read + +concurrency: + group: store6-${{ github.ref }}-${{ (github.event.action == 'labeled' || github.event.action == 'unlabeled') && 'docs-sync-guard' || 'validation' }} + cancel-in-progress: true + +jobs: + docs-sync-guard: + if: ${{ github.event_name == 'pull_request' }} + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Require documentation sync acknowledgment + shell: bash + env: + DOCS_SYNC_ACK: ${{ contains(github.event.pull_request.labels.*.name, 'docs-sync-ack') }} + run: | + set -euo pipefail + source_list=.github/docs-sync-sources.txt + + if [[ ! -s "${source_list}" ]]; then + echo "ERROR: ${source_list} must be non-empty." >&2 + exit 1 + fi + if ! LC_ALL=C sort -c "${source_list}"; then + echo "ERROR: ${source_list} must be sorted." >&2 + exit 1 + fi + + changed_sources="$( + comm -12 \ + <(LC_ALL=C sort "${source_list}") \ + <(git diff --name-only origin/main...HEAD | LC_ALL=C sort) + )" + if [[ -z "${changed_sources}" || "${DOCS_SYNC_ACK}" == "true" ]]; then + exit 0 + fi + + printf 'Documentation sources changed:\n%s\n' "${changed_sources}" >&2 + cat >&2 <<'EOF' + This PR edits sources published to the docs site (listed in .github/docs-sync-sources.txt; pinned by store-docs evidence/T4-store6-source-lock.json). Merging makes the site stale until the docs repo re-pins. Add the 'docs-sync-ack' label to proceed; the docs repo's scheduled drift check will open the re-pin PR after merge. If your edit inserts or deletes lines in STABILITY.md, ROADMAP.md, store6-compose/README.md, or store6-sqldelight/README.md above an existing section, prefer appending — the site applies line-anchored publication transforms to these files. + EOF + exit 1 + + linux-build-test: + runs-on: ubuntu-latest + # The store6-mutations Lincheck budget measured ~58-59m locally and 2h40m-3h13m hosted + # at the current suite (and once 9h23m locally at an earlier revision). The default + # jvmTest excludes it via the build-gated filter in store6-mutations/build.gradle.kts + # (census-guarded below); the scheduled "Store6 full mutations JVM suite" workflow runs + # the full suite daily. + timeout-minutes: 120 + env: + # Kotlin JS/Wasm lock tasks must see one complete project graph across separate Gradle steps. + GRADLE_OPTS: -Dorg.gradle.configureondemand=false + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Cache Kotlin/Native compiler + uses: actions/cache@v4 + with: + path: ~/.konan + key: ${{ runner.os }}-konan-${{ hashFiles('gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties', '**/*.gradle.kts') }} + restore-keys: | + ${{ runner.os }}-konan- + + - name: Grant execute permission for Gradlew + run: chmod +x gradlew + + - name: Build Store6 core + run: > + ./gradlew :store6-core:build :store6-testing:build :store6-sqldelight:build + -Pkotlin.native.enableKlibsCrossCompilation=true + -Pkotlin.apple.xcodeCompatibility.nowarn=true + --stacktrace + + - name: Run Store6 quickstart + run: ./gradlew :store6-quickstart:run --stacktrace + + - name: Run Store6 sqldelight sample + run: ./gradlew :store6-sqldelight-sample:run --stacktrace + + - name: Build Store6 extension probe (seam-only consumer) + run: > + ./gradlew :store6-extension-probe:build + -Pkotlin.native.enableKlibsCrossCompilation=true + -Pkotlin.apple.xcodeCompatibility.nowarn=true + --stacktrace + + - name: Build Store6 compose and demo + run: > + ./gradlew :store6-compose:build :store6-compose-demo:build + -Pkotlin.native.enableKlibsCrossCompilation=true + -Pkotlin.apple.xcodeCompatibility.nowarn=true + --stacktrace + + - name: Verify Compose stability of core public types + shell: bash + run: | + set -euo pipefail + reports_dir=store6-compose-demo/build/compose-reports + # Force a complete, non-incremental report. Kotlin incremental compilation rewrites the + # stability report with ONLY the recompiled subset, and the report is an undeclared task + # output, so neither deleting it nor editing a source guarantees a whole-module snapshot. + # Discarding the module's build directory makes the next compile a full one. The demo + # module's compilations are also opted out of the build cache (see its build.gradle.kts) + # so this cannot be served from cache without running the compiler. + rm -rf store6-compose-demo/build + ./gradlew :store6-compose-demo:compileKotlin --stacktrace + if [[ "$(find "${reports_dir}" -name '*-composables.txt' 2>/dev/null | wc -l | tr -d ' ')" -eq 0 ]]; then + echo "ERROR: no composables report under ${reports_dir}" >&2 + echo "The compose metrics wiring in store6-compose-demo/build.gradle.kts was removed or renamed." >&2 + exit 1 + fi + # All reports are concatenated (deterministic; probes exist only in the main compilation, + # so tier counts stay exact regardless of how many reports the plugin writes — the test + # compilation writes a separate, empty *_test-composables.txt and never clobbers it). + # + # Every probe parameter must be rendered EXPLICITLY `stable`. Asserting the mere absence + # of `unstable` would pass silently on the compiler's third rendering — a bare, unprefixed + # `value: X` line, which it emits for unknown/runtime stability. + # + # iface_strict=1 asserts that the shipped conf renders every probe parameter stable, + # including the interface-typed and generic ones. Set it to 0 only if a toolchain bump + # makes interface-typed parameters unprovable, which downgrades the iface tier to + # skippable-only and reports the residual instead of failing. + find "${reports_dir}" -name '*-composables.txt' | sort | xargs cat | awk -v iface_strict=1 ' + /fun / { + tier = 0 + if ($0 ~ /ProbeStrict/) { tier = 1; strict += 1 } + else if ($0 ~ /ProbeIface/) { tier = 2; iface += 1 } + if (tier > 0 && $0 !~ /skippable/) { print "NOT SKIPPABLE: " $0; bad = 1 } + next + } + /^\)/ { tier = 0; next } + tier > 0 { + if ($1 != "stable") { + label = (tier == 1) ? "strict" : "iface" + if (tier == 1 || iface_strict) { + print "PARAM NOT STABLE (" label " tier): " $0 + bad = 1 + } else { + print "INFO: non-stable iface-tier param (allowed when iface_strict=0): " $0 + } + } + } + END { + if (strict != 8) { print "Expected 8 ProbeStrict composables, found " strict + 0; exit 1 } + if (iface != 5) { print "Expected 5 ProbeIface composables, found " iface + 0; exit 1 } + exit bad + 0 + } + ' + + - name: Build Store6 Room adapter + run: > + ./gradlew :store6-room:build + -Pkotlin.native.enableKlibsCrossCompilation=true + -Pkotlin.apple.xcodeCompatibility.nowarn=true + --stacktrace + + - name: Run Store6 Room sample + run: ./gradlew :store6-room-sample:run --stacktrace + + - name: Build Store6 benchmarks + run: ./gradlew :store6-benchmarks:build --stacktrace + + - name: Build Store6 devtools modules and demo + run: > + ./gradlew :store6-devtools:build :store6-devtools-inspector:build :store6-devtools-demo:build + -Pkotlin.native.enableKlibsCrossCompilation=true + -Pkotlin.apple.xcodeCompatibility.nowarn=true + --stacktrace + + - name: Build Store6 mutations + run: > + ./gradlew :store6-mutations:build + -Pkotlin.native.enableKlibsCrossCompilation=true + -Pkotlin.apple.xcodeCompatibility.nowarn=true + --stacktrace + + - name: Run Store6 mutations quickstart + run: ./gradlew :store6-mutations-quickstart:run --stacktrace + + - name: Census — default jvmTest must execute exactly the non-Lincheck suites + shell: bash + run: | + set -euo pipefail + expected=$(( $(find store6-mutations/src/commonTest store6-mutations/src/jvmTest -name '*Test.kt' | wc -l | tr -d ' ') - 1 )) + executed=$(find store6-mutations/build/test-results/jvmTest -name 'TEST-*.xml' | wc -l | tr -d ' ') + echo "expected=${expected} executed=${executed}" + [ "${expected}" -eq "${executed}" ] + + - name: Build Store6 mutation journal support + run: > + ./gradlew :store6-mutations-testing:build :store6-mutations-sqldelight:build + -Pkotlin.native.enableKlibsCrossCompilation=true + -Pkotlin.apple.xcodeCompatibility.nowarn=true + --stacktrace + + - name: Build Store6 paging-androidx + run: > + ./gradlew :store6-paging-androidx:build + -Pkotlin.native.enableKlibsCrossCompilation=true + -Pkotlin.apple.xcodeCompatibility.nowarn=true + --stacktrace + + - name: Run Store6 paging sample + run: ./gradlew :store6-paging-androidx-sample:run --stacktrace + + - name: Build Store6 graphql + run: > + ./gradlew :store6-graphql:build + -Pkotlin.native.enableKlibsCrossCompilation=true + -Pkotlin.apple.xcodeCompatibility.nowarn=true + --stacktrace + + - name: Run Store6 graphql sample + run: ./gradlew :store6-graphql-sample:run --stacktrace + + - name: Build Store6 realtime + run: > + ./gradlew :store6-realtime:build + -Pkotlin.native.enableKlibsCrossCompilation=true + -Pkotlin.apple.xcodeCompatibility.nowarn=true + --stacktrace + + - name: Run Store6 realtime sample + run: ./gradlew :store6-realtime-sample:run --stacktrace + + - name: Reject core-internal access from extension modules + shell: bash + run: | + set -euo pipefail + status=0 + for module in store6-extension-probe store6-testing store6-sqldelight store6-compose store6-compose-demo store6-room store6-benchmarks store6-devtools store6-devtools-inspector store6-devtools-demo store6-mutations store6-mutations-quickstart store6-mutations-testing store6-mutations-sqldelight store6-paging-androidx store6-paging-androidx/sample store6-graphql store6-graphql/sample store6-realtime store6-realtime/sample; do + source_dir="${module}/src" + if [[ ! -d "${source_dir}" ]]; then + echo "ERROR: expected extension source directory is missing: ${source_dir}" >&2 + status=1 + continue + fi + if grep -rnE 'InternalStoreApi|org[.]mobilenativefoundation[.]store6[.]core[.]internal' "${source_dir}"; then + echo "ERROR: ${module} accesses store6-core internals" >&2 + status=1 + else + grep_status=$? + if [[ "${grep_status}" -ne 1 ]]; then + echo "ERROR: grep failed for ${source_dir} with status ${grep_status}" >&2 + status=1 + fi + fi + done + exit "${status}" + + - name: Verify seam package matches the TD-13 freeze list + shell: bash + run: | + set -euo pipefail + seam_dir="store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam" + expected=( + Bookkeeper.kt + Fetcher.kt + FetcherResult.kt + FreshnessValidator.kt + KeyEvents.kt + Overlay.kt + SourceOfTruth.kt + StoreResults.kt + StoreRuntime.kt + StoreTelemetry.kt + StoreWriteHandle.kt + TransactionalSourceOfTruth.kt + WallClock.kt + ) + diff -u \ + <(printf '%s\n' "${expected[@]}" | LC_ALL=C sort) \ + <(LC_ALL=C ls -1A "${seam_dir}" | LC_ALL=C sort) + + - name: Enforce the TD-8 primitive whitelist and single-writer residence + shell: bash + run: | + set -euo pipefail + status=0 + banned_regex='(^|[^[:alnum:]_])(runBlocking|GlobalScope|atomicfu|Channel|actor)([^[:alnum:]_]|$)|kotlinx[.]coroutines[.]channels[.][*]' + shopt -s nullglob + production_source_dirs=(store6-core/src/*Main store6-testing/src/*Main store6-sqldelight/src/*Main store6-compose/src/*Main store6-room/src/*Main store6-devtools/src/*Main store6-devtools-inspector/src/*Main store6-mutations/src/*Main store6-mutations-testing/src/*Main store6-mutations-sqldelight/src/*Main store6-paging-androidx/src/*Main store6-graphql/src/*Main store6-realtime/src/*Main) + for source_dir in "${production_source_dirs[@]}"; do + [[ -d "${source_dir}" ]] || continue + if grep -rnE --include='*.kt' "${banned_regex}" "${source_dir}"; then + echo "ERROR: banned concurrency primitive found in ${source_dir}" >&2 + status=1 + else + grep_status=$? + if [[ "${grep_status}" -ne 1 ]]; then + echo "ERROR: grep failed for ${source_dir} with status ${grep_status}" >&2 + status=1 + fi + fi + done + key_engine="store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyEngine.kt" + python3 - "${key_engine}" <<'PY' || status=1 + import re + import sys + from pathlib import Path + + assignment = re.compile(r"\bresidence\s*\.\s*value\s*=(?!=)") + + + def mask_non_code(source: str) -> str: + output: list[str] = [] + state = "code" + block_depth = 0 + index = 0 + + def mask(text: str) -> None: + output.extend(char if char in "\r\n" else " " for char in text) + + while index < len(source): + if state == "code": + if source.startswith("//", index): + mask(source[index:index + 2]) + index += 2 + state = "line-comment" + elif source.startswith("/*", index): + mask(source[index:index + 2]) + index += 2 + block_depth = 1 + state = "block-comment" + elif source.startswith('"""', index): + mask(source[index:index + 3]) + index += 3 + state = "raw-string" + elif source[index] == '"': + mask(source[index]) + index += 1 + state = "string" + elif source[index] == "'": + mask(source[index]) + index += 1 + state = "char" + else: + output.append(source[index]) + index += 1 + elif state == "line-comment": + mask(source[index]) + if source[index] == "\n": + state = "code" + index += 1 + elif state == "block-comment": + if source.startswith("/*", index): + mask(source[index:index + 2]) + index += 2 + block_depth += 1 + elif source.startswith("*/", index): + mask(source[index:index + 2]) + index += 2 + block_depth -= 1 + if block_depth == 0: + state = "code" + else: + mask(source[index]) + index += 1 + elif state in ("string", "char"): + delimiter = '"' if state == "string" else "'" + if source[index] == "\\": + end = min(index + 2, len(source)) + mask(source[index:end]) + index = end + else: + closing = source[index] == delimiter + mask(source[index]) + index += 1 + if closing: + state = "code" + elif state == "raw-string": + if source.startswith('"""', index): + mask(source[index:index + 3]) + index += 3 + state = "code" + else: + mask(source[index]) + index += 1 + + if state not in ("code", "line-comment"): + raise ValueError(f"unterminated Kotlin lexical state: {state}") + return "".join(output) + + + source_path = Path(sys.argv[1]) + try: + source = source_path.read_text(encoding="utf-8") + raw_count = len(assignment.findall(source)) + code_count = len(assignment.findall(mask_non_code(source))) + except (OSError, UnicodeError, ValueError) as error: + print(f"ERROR: writer audit failed for {source_path}: {error}", file=sys.stderr) + raise SystemExit(1) + + if raw_count != 1 or code_count != 1: + print( + f"ERROR: expected exactly one raw and one code-level residence.value " + f"assignment in {source_path}; raw_count={raw_count}, code_count={code_count}", + file=sys.stderr, + ) + raise SystemExit(1) + print( + f"Verified residence.value assignment counts in {source_path}: " + f"raw_count={raw_count}, code_count={code_count}" + ) + PY + exit "${status}" + + - name: JS lock-discipline canary (full conformance suite on the JS lane) + run: ./gradlew :store6-core:jsNodeTest :store6-testing:jsNodeTest :store6-mutations:jsNodeTest :store6-mutations-testing:jsNodeTest :store6-paging-androidx:jsNodeTest :store6-graphql:jsNodeTest :store6-realtime:jsNodeTest --stacktrace + + - name: Upload test reports + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: test-reports-store6-linux-${{ github.run_id }}-${{ github.run_attempt }} + path: | + store6-*/build/test-results/**/*.xml + store6-*/build/reports/tests/** + if-no-files-found: warn + retention-days: 7 + + apple-tests: + runs-on: macos-latest + timeout-minutes: 40 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Cache Kotlin/Native compiler + uses: actions/cache@v4 + with: + path: ~/.konan + key: ${{ runner.os }}-konan-${{ hashFiles('gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties', '**/*.gradle.kts') }} + restore-keys: | + ${{ runner.os }}-konan- + + - name: Grant execute permission for Gradlew + run: chmod +x gradlew + + - name: Select an available iPhone simulator + id: ios_simulator + shell: bash + run: | + device_name="$( + xcrun simctl list devices available -j | + jq -r '[.devices[][] | select(.name | startswith("iPhone"))] | first | .name // empty' + )" + if [[ -z "${device_name}" ]]; then + echo "No available iPhone simulator was found." >&2 + exit 1 + fi + echo "Selected iPhone simulator: ${device_name}" + echo "device_name=${device_name}" >> "${GITHUB_OUTPUT}" + + - name: Run Store6 Apple tests + env: + STORE6_IOS_SIMULATOR_DEVICE: ${{ steps.ios_simulator.outputs.device_name }} + run: | + ./gradlew \ + :store6-core:iosSimulatorArm64Test \ + :store6-core:macosArm64Test \ + :store6-extension-probe:iosSimulatorArm64Test \ + :store6-extension-probe:macosArm64Test \ + :store6-testing:iosSimulatorArm64Test \ + :store6-testing:macosArm64Test \ + :store6-sqldelight:iosSimulatorArm64Test \ + :store6-sqldelight:macosArm64Test \ + :store6-compose:iosSimulatorArm64Test \ + :store6-compose:macosArm64Test \ + :store6-room:iosSimulatorArm64Test \ + :store6-room:macosArm64Test \ + :store6-paging-androidx:iosSimulatorArm64Test \ + :store6-paging-androidx:macosArm64Test \ + :store6-graphql:iosSimulatorArm64Test \ + :store6-graphql:macosArm64Test \ + :store6-realtime:iosSimulatorArm64Test \ + :store6-realtime:macosArm64Test \ + :store6-devtools:iosSimulatorArm64Test \ + :store6-devtools:macosArm64Test \ + :store6-devtools-inspector:iosSimulatorArm64Test \ + :store6-devtools-inspector:macosArm64Test \ + :store6-mutations:iosSimulatorArm64Test \ + :store6-mutations:macosArm64Test \ + :store6-mutations-testing:iosSimulatorArm64Test \ + :store6-mutations-testing:macosArm64Test \ + :store6-mutations-sqldelight:iosSimulatorArm64Test \ + :store6-mutations-sqldelight:macosArm64Test \ + "-Pstore6.iosSimulatorDevice=${STORE6_IOS_SIMULATOR_DEVICE}" \ + --stacktrace + + - name: Link Store6 devtools demo iOS framework + run: ./gradlew :store6-devtools-demo:linkDebugFrameworkIosSimulatorArm64 --stacktrace + + - name: Upload test reports + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: test-reports-store6-apple-${{ github.run_id }}-${{ github.run_attempt }} + path: | + store6-*/build/test-results/**/*.xml + store6-*/build/reports/tests/** + if-no-files-found: warn + retention-days: 7 + + swift-dumps: + runs-on: macos-latest + timeout-minutes: 40 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Cache Kotlin/Native compiler + uses: actions/cache@v4 + with: + path: ~/.konan + key: ${{ runner.os }}-konan-${{ hashFiles('gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties', '**/*.gradle.kts') }} + restore-keys: | + ${{ runner.os }}-konan- + + - name: Grant execute permission for Gradlew + run: chmod +x gradlew + + - name: Check committed Swift dumps + run: ./gradlew checkSwiftDumps --stacktrace + + - name: Upload Swift dump diagnostics + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: swift-dump-diagnostics-${{ github.run_id }}-${{ github.run_attempt }} + path: | + store6-swift-dumps/**/build/swift-dump/** + store6-swift-dumps/**/build/bin/iosArm64/debugFramework/**/*.h + store6-swift-dumps/**/build/skie/** + store6-core/api/swift/** + store6-mutations/api/swift/** + if-no-files-found: warn + retention-days: 7 + + klib-publication-check: + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Cache Kotlin/Native compiler + uses: actions/cache@v4 + with: + path: ~/.konan + key: ${{ runner.os }}-konan-${{ hashFiles('gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties', '**/*.gradle.kts') }} + restore-keys: | + ${{ runner.os }}-konan- + + - name: Grant execute permission for Gradlew + run: chmod +x gradlew + + - name: Publish Store6 core to Maven local without signing + run: > + ./gradlew :store6-core:publishToMavenLocal :store6-testing:publishToMavenLocal :store6-sqldelight:publishToMavenLocal :store6-compose:publishToMavenLocal :store6-room:publishToMavenLocal :store6-devtools:publishToMavenLocal :store6-devtools-inspector:publishToMavenLocal :store6-mutations:publishToMavenLocal :store6-mutations-testing:publishToMavenLocal :store6-mutations-sqldelight:publishToMavenLocal :store6-paging-androidx:publishToMavenLocal :store6-graphql:publishToMavenLocal :store6-realtime:publishToMavenLocal + -Pkotlin.native.enableKlibsCrossCompilation=true + -Pkotlin.apple.xcodeCompatibility.nowarn=true + --stacktrace + + - name: Verify common and target publications + shell: bash + run: | + group_id="org.mobilenativefoundation.store" + version="6.0.0-SNAPSHOT" + repository="${HOME}/.m2/repository/org/mobilenativefoundation/store" + + modules=(store6-core store6-testing store6-sqldelight store6-compose store6-room store6-devtools store6-devtools-inspector store6-mutations store6-mutations-testing store6-mutations-sqldelight store6-paging-androidx store6-graphql store6-realtime) + suffixes=( + "" + -android + -iosarm64 + -iossimulatorarm64 + -iosx64 + -js + -jvm + -linuxx64 + -macosarm64 + -mingwx64 + -tvosarm64 + -wasm-js + -watchosarm64 + ) + + failed=0 + for module in "${modules[@]}"; do + for suffix in "${suffixes[@]}"; do + case "${module}:${suffix}" in + store6-paging-androidx:-iosx64) + # store6-paging-androidx ships the paging-common-3.5.1 target subset; androidx.paging + # publishes no Intel artifacts since 3.4.0-rc01. + continue + ;; + store6-room:-js|store6-room:-wasm-js|store6-room:-mingwx64|store6-room:-iosx64) + # store6-room ships Room 3's target subset; room3 publishes no iosX64 and no + # web/mingw klibs for this module's scope. + continue + ;; + store6-devtools-inspector:-watchosarm64|store6-devtools-inspector:-tvosarm64|store6-devtools-inspector:-linuxx64|store6-devtools-inspector:-mingwx64) + # store6-devtools-inspector ships the CMP-UI subset; Compose foundation/material3 + # publish no watchOS/tvOS/Linux/MinGW variants. + continue + ;; + esac + artifact_id="${module}${suffix}" + artifact_dir="${repository}/${artifact_id}/${version}" + case "${suffix}" in + ""|-jvm) + extension="jar" + ;; + -android) + extension="aar" + ;; + *) + extension="klib" + ;; + esac + artifact_file="${artifact_dir}/${artifact_id}-${version}.${extension}" + if [[ ! -f "${artifact_file}" ]]; then + echo "Missing consumable publication artifact: ${artifact_file}" >&2 + failed=1 + continue + fi + echo "Verified ${group_id}:${artifact_id}:${version} (${extension})" + done + done + exit "${failed}" + + native-stress: + runs-on: macos-latest + timeout-minutes: 40 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Cache Kotlin/Native compiler + uses: actions/cache@v4 + with: + path: ~/.konan + key: ${{ runner.os }}-konan-${{ hashFiles('gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties', '**/*.gradle.kts') }} + restore-keys: | + ${{ runner.os }}-konan- + + - name: Grant execute permission for Gradlew + run: chmod +x gradlew + + - name: Run Store6 Native stress and soak lane (macOS) + shell: bash + run: | + set -euo pipefail + ./gradlew :store6-core:macosArm64Test \ + --tests '*StoreEvictionStressTest' \ + --tests '*StoreInvalidationStressTest' \ + --tests '*StoreCloseLifecycleTest' \ + --tests '*StoreBackpressureConformanceTest' \ + --stacktrace + + result_dir="store6-core/build/test-results/macosArm64Test" + expected_test_classes=( + StoreEvictionStressTest + StoreInvalidationStressTest + StoreCloseLifecycleTest + StoreBackpressureConformanceTest + ) + python3 - "${result_dir}" "${expected_test_classes[@]}" <<'PY' + import sys + from pathlib import Path + import xml.etree.ElementTree as ET + + + def local_name(tag: str) -> str: + return tag.rsplit("}", 1)[-1] + + + result_dir = Path(sys.argv[1]) + expected_test_classes = sys.argv[2:] + result_files = sorted(result_dir.glob("TEST-*.xml")) + if not result_files: + print(f"ERROR: no Kotlin/Native test-result XML found in {result_dir}", file=sys.stderr) + raise SystemExit(1) + + executed = {test_class: 0 for test_class in expected_test_classes} + for result_file in result_files: + try: + root = ET.parse(result_file).getroot() + except (OSError, ET.ParseError) as error: + print(f"ERROR: failed to parse {result_file}: {error}", file=sys.stderr) + raise SystemExit(1) + + for testcase in root.iter(): + if local_name(testcase.tag) != "testcase": + continue + if any(local_name(child.tag) == "skipped" for child in testcase): + continue + classname = testcase.get("classname", "") + for test_class in expected_test_classes: + if classname == test_class or classname.endswith(f".{test_class}"): + executed[test_class] += 1 + + missing = [test_class for test_class, count in executed.items() if count == 0] + for test_class, count in executed.items(): + if count: + print(f"Verified {count} executed Native testcase(s) for {test_class}") + if missing: + print( + "ERROR: no non-skipped Native testcase evidence for: " + ", ".join(missing), + file=sys.stderr, + ) + raise SystemExit(1) + PY + + - name: Upload test reports + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: test-reports-store6-native-stress-${{ github.run_id }}-${{ github.run_attempt }} + path: | + store6-*/build/test-results/**/*.xml + store6-*/build/reports/tests/** + if-no-files-found: warn + retention-days: 7 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..63cb73676 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,66 @@ +# Agent instructions + +These instructions govern any agent working in this repository. They apply to every +documentation surface: READMEs, KDoc and doc comments, inline comments, workflow YAML +comments, commit messages, and pull-request bodies. + +## Documentation discipline + +The full rules are embedded in this repository as skills: +`.claude/skills/documentation-discipline/` (governs every sentence) and +`.claude/skills/code-documentation/` (governs evidence, artifact shape, mutation, and +verification; composes with the discipline skill). Agents with skill support invoke them by +name before documentation work; agents without it read the `SKILL.md` files and their +`references/` directly. The core rules below are the load-bearing summary and apply either +way. + +### The master test + +Keep a documentation element only when the reader needs it to use, change, operate, or +reason about the documented system correctly. Cut throat-clearing, hype, vague benefits, +decorative language, narration of visible syntax or control flow, and invented precision. + +### Protected technical content + +A wording pass never alters identifiers, signatures, commands, paths, URLs, versions, +numbers, units, measurements, schema fields, error names, compatibility statements, +behavioral guarantees, or evidence classifications. Semantic directives and behavior-bearing +comments (suppressions, build constraints, generator markers, tool configuration) are +protected even though they are syntactically comments. If a requested change requires +altering a protected token, stop and report it as a technical change needing its own +authorization — do not fold it into a style pass silently. + +### No internal organizational context in code surfaces + +Code documentation must be self-contained and durable. Do not put issue-tracker IDs, +internal project or initiative names, ruling or approval shorthand, landing status, team +shorthand, or internal revision labels into source comments, workflow comments, or step +names. State the technical fact with a durable attribution instead — for example +"measured 2h40m-3h13m on hosted runners at the current suite" rather than a tracker +reference. Pull-request bodies and commit messages may reference issues and process records; +source files may not. + +### Evidence before claims + +State confirmed facts. Label uncertainty explicitly. Verify a claim before writing it — a +coverage or completeness claim ("every X now does Y") requires an actual sweep, not an +extrapolation from the files you happened to touch. Never present a failed command or +example as working. + +### Repository comment conventions + +Some comment blocks are deliberately byte-identical across sibling files (for example the +test-deadline wrapper comment that appears with the shared `runTest` shim in test files). +Match the established shape exactly when extending such a pattern; do not reword one copy. +When editing near an existing comment, preserve it unless it is wrong — revise only the +evidenced deficiency. + +### Three-pass review + +Before finishing documentation work, run three separate passes: + +1. **Accuracy** — every protected token, contract, and classification unchanged and correct + against the source. +2. **Warrant** — every remaining claim supported by evidence or labeled as uncertain. +3. **Reader utility** — the intended reader can complete their task without missing + prerequisites, boundaries, units, risks, or operational consequences. diff --git a/README.md b/README.md index f67ae8b45..31a632549 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,34 @@ [![codecov](https://codecov.io/gh/MobileNativeFoundation/Store/branch/main/graph/badge.svg?token=0UCmG3QHPf)](https://codecov.io/gh/MobileNativeFoundation/Store) +## Store 6 + +Store 6 is the next major line, published under `store6-*` coordinates alongside Store 5 for the +whole 6.x major. It is a Kotlin Multiplatform library for reading and writing data that lives in +more than one place: a network, a local database, and memory. You describe a key and a fetcher, and +Store handles single-flighting concurrent demand, staleness, invalidation, and bounded memory, with +every zero-config behavior named and covered by a conformance test you can read. + +**Status: in development, targeting 6.0.0-alpha01.** Nothing is published yet. + +Two things about the first alpha, stated up front rather than discovered later: + +- **Mutations ship experimental.** `store6-mutations` is a separate artifact and every public symbol + is `@ExperimentalStoreApi`. The tier is on the artifact, never annotation-gated inside a stable + one. +- **Mutations ship the two-step durable ack posture.** The non-transactional acknowledgement path + adopts the server echo first and retires the journal row last, so a crash inside that window + leaves a replayable pending intent rather than losing the write. The consequence is that the same + push can be re-sent after such a crash, so design those endpoints to be idempotent. Making the ack + path atomic is beta01 work, not alpha01 work. + +The full policy — API tiers, the deprecation cycle, the cadence commitment, and how you can verify +all of it from a released tag — is in [STABILITY.md](./STABILITY.md). The public roadmap is at +[ROADMAP.md](./ROADMAP.md), and the quickstart is at +[docs/store6/quickstart.md](./docs/store6/quickstart.md). + +--- + #### Documentation Comprehensive guides, tutorials, and API reference: [store.mobilenativefoundation.org](https://store.mobilenativefoundation.org). diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 000000000..0f79e8e72 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,113 @@ +# Store 6 roadmap + +Store 6's plan, with dates on it. Some of those dates will move. What will not move is the rule +that governs how they move, stated in the first section below. Where a window is an estimate, this +page says so and gives the range. + +This is the roadmap [#534][534] asked for. + +## Operating principles + +These are commitments, not aspirations. + +1. **Cut scope, never cadence.** A slip threatens a release's contents, never its date. +2. **The read core never waits on an extension.** If paging, the Swift facade, or Store 5 interop + slips, 6.0 still ships as a complete, stable read library without it. Mutations are not an + extension for this rule's purposes — writing is functionality Store 5 already shipped, and Store 6 + is not publishable to this community without it. It lives in a separate artifact because its API + is experimental, which is a packaging decision, not a dependency one. +3. **Experimental code lives in separate artifacts.** Never annotation-gated inside a stable + artifact, so a tier is always visible on the thing you depend on. +4. **Docs are launch gates, not follow-ups.** A release without its documentation is not done, and + migration guides ship with the migration. +5. **Gates are written down before the work starts**, so a feature ships when its criteria pass + rather than when enthusiasm peaks. + +## Release train + +### Foundation (Q3–Q4 2026) + +The build, the target matrix, the CI lanes, and the API-review discipline, proven end to end before +depth is added. Binary-compatibility and generated-Swift dumps gated in CI from the first alpha. +Store 6 is developed in a fork and lands in this repository under `store6.*` before the alpha01 cut. +History, stars, and watchers stay here. + +### 6.0.0-alpha01 — target Q4 2026 (confidence range Q4 2026 – Q1 2027) + +The list is split into a floor that defines the release and deliverables that may slip a month +under principle 1. The confidence range above is real: treat Q1 2027 as the honest outer bound. + +**The floor — these are alpha01:** + +| | | +|---|---| +| `store6-core`, `store6-testing` | The engine and its conformance kit. | +| `store6-mutations` | The write path: journal, drain, rebase, conflict stack, restart replay. Experimental artifact, in the floor rather than the may-slip list. | +| STABILITY.md + this roadmap | The published policy: tiers, deprecation cycle, cadence commitment. | +| Quickstart + Important Defaults | The mental model before the API reference. | + +**May slip one alpha:** the SQLDelight, Room, and Compose adapters, the devtools MVP, and the +remaining documentation pages. Anything that slips gets its target alpha named in the release notes. + +### Mutations beta train + 6.0.0-beta01 (Q1–Q2 2027) + +Ack-path atomicity and its crash matrix, the Paging 3 interop adapter, the Swift SPM facade against +the freeze-candidate core, the outbox inspector demo, and Store 5 interop with migration lint. + +beta01 is the **core API freeze candidate**. From beta01 forward, no source-breaking core change +without an RC reset. + +A word on what "freeze" means here, because it is the promise most worth being precise about. The +seam you implement to plug in your own fetcher, source of truth, bookkeeper, clock, telemetry, or +overlay becomes a freeze **candidate** once a real producer has exercised it end to end, which the +mutations work does before alpha01. It becomes **frozen** only after the ack-path atomicity work and +its test matrix are green. If that work misses beta01, the overlay and write-handle surfaces ship +experimental outside the frozen tier and the rest of the core freezes on schedule. Two stages, both +stated, neither skipped. + +### 6.0.0 GA — target Q3 2027 (confidence range Q3 – Q4 2027) + +Core, testing, the adapters, Store 5 interop, and the BOM in the stable tier, the adapters having +run the contract kit throughout the alpha line. Paging ships alongside as a supported experimental +artifact with the tier on the tin. The 5→6 and "Store 4 → 6 in an afternoon" migration guides both +block GA. Store 5 moves to fixes-only maintenance with a dated end-of-life published at GA. + +### After GA + +6.1 brings the first mutations graduation review. 6.2 is gated rather than dated. 6.3 is the target +window for mutations graduation to stable. + +## Cadence + +**Monthly alphas from 6.0.0-alpha01.** Each release names the next release's target month, and each +one closes at least one community issue with a link to the named guarantee that resolves it — a +conformance test, not a changelog line. + +Full policy, including the deprecation cycle and how to verify any of this from a released tag, is +in [STABILITY.md](./STABILITY.md). + +## Mutations graduation + +Mutations stay experimental past GA. The first review is at 6.1, and the target window for +graduation is roughly 6.3. Graduation requires the API unchanged across two consecutive minors, +crash-matrix and soak lanes green in production-representative apps, and at least three external +production adopters reporting. If those are not met, it stays experimental and the review repeats. +There is no date-driven graduation. + +## How to contribute + +- **Documentation.** Every page in this line names the source it was written from, and code blocks + come from modules CI compiles. If a page loses you, open an issue saying where. That is a useful + bug report, and it is the one we most want. +- **Semantics.** The conformance suite under + [`store6-core/src/commonTest`](store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/) + is the specification. If you can describe a behavior you expected and a test that would have + caught it, that is a complete contribution before a line of implementation. +- **Adapters and platforms.** The source-of-truth seam is small on purpose. An adapter for a store + we do not cover is a self-contained contribution. +- **Where to talk.** The [#store](https://kotlinlang.slack.com/archives/C06007Z01HU) channel on + Kotlin Slack, or an issue on this repository. + +Issues that name a concrete expectation get answered with a test. That is the on-ramp. + +[534]: https://github.com/MobileNativeFoundation/Store/issues/534 diff --git a/STABILITY.md b/STABILITY.md new file mode 100644 index 000000000..8bdfe23b2 --- /dev/null +++ b/STABILITY.md @@ -0,0 +1,209 @@ +# Store 6 stability policy + +## 1. What this document is + +What each `store6-*` artifact promises, how an API is allowed to change, how often we ship, and how +you can verify all of it from a released tag. Where a promise is not yet earned, this document says +so rather than rounding up. + +It is also the standing answer to [#570][570] on binary compatibility and [#534][534] on a published +roadmap. + +Scope: the `store6-*` artifacts, effective with the 6.0.0-alpha01 release. Store 5 continues under +its own coordinates, and [§6](#6-migrating-from-store-5) covers living with both. + +## 2. API tiers + + + +Store 6 uses three opt-in markers. Each is a real annotation in `store6-core`, and the meaning below +is the one carried in its own KDoc. + +| Marker | Means | +|---|---| +| `@ExperimentalStoreApi` | API under active development that **may change or be removed in any release**. Experimental API ships in separate artifacts wherever possible; the marker exists for the cases where an experimental member must live beside stable API. | +| `@DelicateStoreApi` | API that is **stable but easy to misuse** — for example implementing `Store` directly instead of building one through the `store { }` DSL. Opting in asserts that you uphold the documented contract of the marked declaration. | +| `@InternalStoreApi` | API **internal to the Store libraries**. It may change or disappear without notice even in patch releases, and must never be used outside `org.mobilenativefoundation.store` artifacts. | + +All three are `RequiresOptIn.Level.ERROR`: you cannot use them by accident. `Store` additionally +carries `@SubclassOptInRequired(DelicateStoreApi::class)`, so implementing the interface yourself is +a deliberate act, not a default. + +**Experimental code lives in separate artifacts, never annotation-gated inside a stable one.** When +a capability needs its own release rhythm, it gets its own artifact, and the tier is stated on the +artifact rather than buried in an annotation on a member you have already depended on. + +**SemVer is scoped to the stable tier.** A breaking change to an `@ExperimentalStoreApi` surface in +a minor release is not a SemVer violation, because that surface never claimed the guarantee. That is +the whole point of stating the tier on the tin. + +## 3. Artifacts and tiers, as of 6.0.0-alpha01 + +Group coordinates are unchanged: `org.mobilenativefoundation.store`. Packages are +`org.mobilenativefoundation.store6.*`. + +| Artifact | Tier | In 6.0.0-alpha01 | +|---|---|---| +| `store6-core` | Stable-track. The API is **not frozen** until the beta01 freeze candidate. | alpha01 | +| `store6-testing` | Experimental (`@ExperimentalStoreApi`) — every public declaration in the artifact carries the marker today. | alpha01 | +| `store6-sqldelight` | Experimental adapter (`@ExperimentalStoreApi`). Graduates to stable at 6.0.0, having run the contract kit throughout the alpha line. | alpha01, may slip one alpha | +| `store6-room` | Experimental adapter, same graduation. | alpha01, may slip one alpha | +| `store6-compose` | Experimental adapter, same graduation. | alpha01, may slip one alpha | +| `store6-mutations` | **Experimental, separate artifact — every public symbol is `@ExperimentalStoreApi`.** See [§8](#mutations). | alpha01 | +| `store6-bom` | Version alignment only; no API surface of its own. | alpha01 | +| `store6-devtools` | Experimental (`@ExperimentalStoreApi`). | alpha02 (target) | +| `store6-devtools-inspector` | Experimental (`@ExperimentalStoreApi`). | alpha02 (target) | + +Inside `store6-core`, the `org.mobilenativefoundation.store6.core.seam` package — the 13 files you +implement to plug in your own fetcher, source of truth, bookkeeper, clock, telemetry, or overlay — +is a **freeze candidate, not frozen.** Today these types are `@ExperimentalStoreApi`, so +implementing one is an explicit opt-in; that is the exception §2 names, and it is why the seam sits +inside a stable-track artifact rather than shipping separately. + +The candidate-versus-frozen distinction is load-bearing and we state it in two stages deliberately. +A real producer has to exercise a seam end to end before we will call it a candidate. The +`Overlay` and `StoreWriteHandle` surfaces become frozen only once the ack-path atomicity work and +its test matrix are green; if that work misses beta01, those two ship `@ExperimentalStoreApi` +outside the frozen tier and the rest of core freezes on schedule. CI enforces the 13-file list on +every pull request, so the seam cannot grow quietly. + +Promised: `store6-store5-interop`, tracking to 6.0.0 and not in the alpha01 line, and +`store6-paging-androidx`, which joins the line in the first release it is green for. An artifact +that misses a train gets its target release named here. It does not get dropped silently. + +## 4. Deprecation cycle + + + +Every removal from the stable tier goes through three stages: + +1. **`WARNING` with `ReplaceWith`.** The replacement is mechanical wherever the shape allows it. +2. **`ERROR`, no earlier than two minor releases later.** You get at least two minors of warning + before your build breaks. +3. **`HIDDEN` at the next major.** Binary compatibility is preserved until then. + +**No silent capability drops.** A removed capability gets the same cycle and a migration note. You +should never find out a capability is gone by upgrading. + +## 5. Release cadence + + + +**Monthly alphas from 6.0.0-alpha01.** The governing rule is **cut scope, never cadence**: a slip +threatens a release's contents, never its date. If something is not ready, it ships in the next +alpha a month later and the release notes say so. + +We will not repeat a 30-month alpha line, and we will not break API in beta again. + +Each alpha closes at least one community issue with a link to the named guarantee that resolves it — +a conformance test, not a changelog line. The next alpha's target month is stated in each release's +notes. This document states the policy. Each release states the date. + +The public roadmap is at [ROADMAP.md](./ROADMAP.md). + +## 6. Migrating from Store 5 + +`store5.*` and `store6.*` coordinates live **side by side for the whole 6.x major**. You can depend +on both in one build and migrate a screen at a time. There is no flag day. + +`store6-store5-interop` is supported for all of 6.x. The 5→6 and 4→6 migration guides are launch +gates for 6.0.0 — they block GA, they are not follow-ups. + +## 7. How stability is verified + + + +Every claim in this document is checkable from a released tag. + +- **`explicitApi()` strict** on every `store6-*` library module. Nothing becomes public by omission. +- **Binary-compatibility-validator (0.17.0) with klib validation enabled.** Each module commits a + JVM `.api` dump and a `.klib.api` dump — for example `store6-core/api/jvm/store6-core.api` and + `store6-core/api/store6-core.klib.api`. The check runs as part of `build` on every pull request, + so an unintended ABI change fails CI before review. +- **Generated-Swift dumps diffed on every pull request** across the supported bridges — Obj-C export + and SKIE today (`store6-core/api/swift/objc`, `store6-core/api/swift/skie`). The bridge set follows + the Swift Export disposition recorded at the alpha01 cut, so read this as a commitment to the + mechanism rather than to a fixed list of lanes. +- **ABI dumps are committed at every released tag**, so the surface of any release is diffable from + the repository without resolving artifacts. +- **The conformance suite is public documentation of what is guaranteed.** The behaviors this + library promises are named tests you can read: + [`store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/`](store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/) + (`*ConformanceTest.kt`). When a release closes one of your issues, the notes link the test, not a + bullet point. + +## 8. Mutations at 6.0.0-alpha01 + + + +`store6-mutations` is in the alpha01 floor, not the may-slip list: an app that writes should not +have to wait for a later alpha. Three things about it are worth stating plainly. + +### (a) The tier + +Experimental, in its own artifact, every public symbol `@ExperimentalStoreApi`. The written +graduation criteria are published alongside the 6.0.0-alpha01 release and linked from this section +then; the first review is at 6.1. The target window for graduation to stable is roughly 6.3, and it +is a target rather than a schedule: graduation requires the API unchanged across two consecutive +minors, +crash-matrix and soak lanes green in production-representative apps, and at least three external +production adopters reporting. If those are not met, it stays experimental and the review repeats. +Nothing graduates because a date arrived. + +### (b) The durable acknowledgement posture + +Every public mutation store has a journal storage, including the in-memory default, which does not +survive process restart. After the server returns an acknowledgement, Store records the receipt, any +pending alias or tombstone, and the `ACKED` execution phase in one journal transaction. Only after +it commits does Store adopt the result, apply effects, and finalize retirement. + +If the server accepts a push before the local acknowledgement commits, the intent remains `INFLIGHT`; +a later drain can resend the same immutable generation and key. Durable storage preserves that replay. +This is the same conservative crash-window stance used for reads: prefer doing work twice over +losing it. + +Once `ACKED` is committed, recovery resumes adoption, effects, and retirement without calling +`MutationServer.push` again for that generation. Those post-acknowledgement steps may repeat +conservatively after a failure, but the accepted write is not sent twice from that durable phase. + +The consequence: design mutation endpoints to treat a repeated idempotency key as the same request. +This covers the remote-acceptance window before the local acknowledgement transaction commits. + +### (c) The surface has been reviewed — and stays experimental + +The mutations API review ran and ruled the surface (twenty rulings, 2026-08-01): the entry point +is the required-input `mutationStore` factory with an overlay-free builder, restart-safe key +recovery is a compile-time-required resolver, the value state is an explicit presence algebra, +and the persistence a caller installs is retained for the transactional ack-path decorator. +The module remains experimental — shapes can change in any release, and this document still +deliberately freezes no mutations signature into policy prose. + +## 9. Reading pending writes and staleness + +Two affordances that look similar are not, and getting them backwards produces UI bugs that are +hard to trace. + +- **A "pending write" affordance keys on `origin == OVERLAY`.** +- **A "stale cache" affordance keys on `isStale`.** + +`isStale` is **never set on an `OVERLAY` frame.** Overlay frames are fresh by definition: they are +stamped `age = Duration.ZERO` and `isStale = false` unconditionally, because an optimistic value +genuinely is new — the user just wrote it. On an overlay frame, only `refreshing` is live. So a +spinner driven by `isStale` will never fire for a pending write, and that is intended. Drive the +pending-write indicator off the origin and narrate the `OVERLAY` → `SOT` flip. + +**`Store.get` is unprojected.** Overlays apply only to `stream`, so an optimistic mutation is +invisible to `get`. This is a documented consequence of the read contract, not a defect: `get` is a +point read of committed truth. If you need to observe your own optimistic write, observe `stream`. + +## 10. Kotlin floor + +The `store6` line requires **Kotlin 2.3**, raised only in minor releases and with notice. + +The floor is what the published artifacts actually imply, not an aspiration: every published +`store6-core` variant — JVM, Android, JS, wasmJs, and each native target — declares +`org.jetbrains.kotlin:kotlin-stdlib:2.3.20`, and the build sets no `apiVersion` or `languageVersion` +compatibility pin that would lower it. Room 3 is what drove the toolchain here. + +[570]: https://github.com/MobileNativeFoundation/Store/issues/570 +[534]: https://github.com/MobileNativeFoundation/Store/issues/534 diff --git a/build.gradle.kts b/build.gradle.kts index b44c87ae5..e0e902816 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,39 +1,106 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class) plugins { - alias(libs.plugins.android.kotlin.multiplatform) apply false - alias(libs.plugins.android.library) apply false - alias(libs.plugins.kotlin.multiplatform) apply false - alias(libs.plugins.kotlin.serialization) apply false - alias(libs.plugins.dokka) apply false - alias(libs.plugins.vanniktech.maven.publish) apply false - alias(libs.plugins.atomicfu) apply false - alias(libs.plugins.kotlin.cocoapods) apply false alias(libs.plugins.ktlint) - alias(libs.plugins.spotless) - alias(libs.plugins.binary.compatibility.validator) apply false - alias(libs.plugins.kmmbridge.github) apply false + id("com.diffplug.spotless") version "6.4.1" + // 010/011/012 toolchain: loaded once here (apply false) so every module shares one + // plugin classloader + version; module branches only `alias(...)` without versions. + alias(libs.plugins.ksp) apply false + alias(libs.plugins.sqldelight) apply false + alias(libs.plugins.room3) apply false + alias(libs.plugins.kotlin.compose.compiler) apply false + alias(libs.plugins.jetbrains.compose) apply false + alias(libs.plugins.kotlinx.benchmark) apply false } -tasks { - withType { - compilerOptions { - jvmTarget = JvmTarget.fromTarget(libs.versions.jvmCompat.get()) +buildscript { + repositories { + mavenCentral() + gradlePluginPortal() + google() + } + + dependencies { + classpath(libs.android.gradle.plugin) + classpath(libs.kotlin.gradle.plugin) + classpath(libs.kotlin.serialization.plugin) + classpath(libs.dokka.gradle.plugin) + classpath(libs.ktlint.gradle.plugin) + classpath(libs.jacoco.gradle.plugin) + classpath(libs.maven.publish.plugin) + classpath(libs.atomic.fu.gradle.plugin) + classpath(libs.kmmBridge.gradle.plugin) + classpath(libs.binary.compatibility.validator) + } +} + +allprojects { + repositories { + mavenCentral() + google() + } +} + +subprojects { + tasks.withType().configureEach { + compilerOptions.jvmDefault.set(org.jetbrains.kotlin.gradle.dsl.JvmDefaultMode.DISABLE) + } + + pluginManager.withPlugin("org.jetbrains.kotlin.multiplatform") { + extensions.configure { + targets.withType().configureEach { + binaries.withType().configureEach { + // Kotlin 2.2.20+ exports KDoc by default; preserve the frozen Swift dumps. + exportKdoc.set(false) + } + } + } + } + + if (name.startsWith("store6")) return@subprojects + + apply(plugin = "org.jlleitschuh.gradle.ktlint") + apply(plugin = "com.diffplug.spotless") + + ktlint { + disabledRules.add("import-ordering") + } + + spotless { + kotlin { + target("src/**/*.kt") } } +} + +tasks { + withType { + compilerOptions.jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } withType().configureEach { - sourceCompatibility = libs.versions.jvmCompat.get() - targetCompatibility = libs.versions.jvmCompat.get() + sourceCompatibility = JavaVersion.VERSION_11.name + targetCompatibility = JavaVersion.VERSION_11.name } } // Workaround for https://youtrack.jetbrains.com/issue/KT-62040 tasks.getByName("wrapper") -tasks.named("updateDaemonJvm") { - // JDK 17 is the minimum version supported by the org.gradle.toolchains.foojay-resolver-convention plugin - languageVersion = JavaLanguageVersion.of(17) - vendor.set(JvmVendorSpec.AZUL) +tasks.register("refreshSwiftDumps") { + dependsOn( + ":store6-swift-dumps-objc:refreshSwiftDump", + ":store6-swift-dumps-skie:refreshSwiftDump", + ":store6-swift-dumps-mutations-objc:refreshSwiftDump", + ":store6-swift-dumps-mutations-skie:refreshSwiftDump", + ) +} + +tasks.register("checkSwiftDumps") { + dependsOn( + ":store6-swift-dumps-objc:checkSwiftDump", + ":store6-swift-dumps-skie:checkSwiftDump", + ":store6-swift-dumps-mutations-objc:checkSwiftDump", + ":store6-swift-dumps-mutations-skie:checkSwiftDump", + ) } diff --git a/cache/README.md b/cache/README.md deleted file mode 100644 index e994c7589..000000000 --- a/cache/README.md +++ /dev/null @@ -1,58 +0,0 @@ -# Cache - -Store depends on a subset of [Guava](https://github.com/google/guava). -This is a shaded artifact that is Kotlin Multiplatform compatible. - -## Usage - -```kotlin -implementation("org.mobilenativefoundation.store:cache:${STORE_VERSION}") -``` - -## Implementation - -### Model the key - -```kotlin -data class Key( - val id: String -) -``` - -### Model the value - -```kotlin -data class Post( - val title: String -) -``` - -### Build the cache - -```kotlin - val cache = CacheBuilder() - .maximumSize(100) - .expireAfterWrite(1.day) - .build() -``` - -## See Also - -https://github.com/google/guava/wiki/CachesExplained - -## License - -```text -Copyright (c) 2017 The New York Times Company - -Copyright (c) 2010 The Guava Authors - -Licensed under the Apache License, Version 2.0 (the "License"); you may not use this library except in -compliance with the License. You may obtain a copy of the License at - -www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an -"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific -language governing permissions and limitations under the License. -``` diff --git a/cache/api/jvm/cache.api b/cache/api/jvm/cache.api deleted file mode 100644 index 76111af4c..000000000 --- a/cache/api/jvm/cache.api +++ /dev/null @@ -1,69 +0,0 @@ -public abstract interface class org/mobilenativefoundation/store/cache5/Cache { - public fun getAllPresent ()Ljava/util/Map; - public abstract fun getAllPresent (Ljava/util/List;)Ljava/util/Map; - public abstract fun getIfPresent (Ljava/lang/Object;)Ljava/lang/Object; - public abstract fun getOrPut (Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)Ljava/lang/Object; - public abstract fun invalidate (Ljava/lang/Object;)V - public abstract fun invalidateAll ()V - public abstract fun invalidateAll (Ljava/util/List;)V - public abstract fun put (Ljava/lang/Object;Ljava/lang/Object;)V - public abstract fun putAll (Ljava/util/Map;)V - public abstract fun size ()J -} - -public final class org/mobilenativefoundation/store/cache5/Cache$DefaultImpls { - public static fun getAllPresent (Lorg/mobilenativefoundation/store/cache5/Cache;)Ljava/util/Map; -} - -public final class org/mobilenativefoundation/store/cache5/CacheBuilder { - public static final field Companion Lorg/mobilenativefoundation/store/cache5/CacheBuilder$Companion; - public fun ()V - public final fun build ()Lorg/mobilenativefoundation/store/cache5/Cache; - public final fun concurrencyLevel (Lkotlin/jvm/functions/Function0;)Lorg/mobilenativefoundation/store/cache5/CacheBuilder; - public final fun expireAfterAccess-LRDsOJo (J)Lorg/mobilenativefoundation/store/cache5/CacheBuilder; - public final fun expireAfterWrite-LRDsOJo (J)Lorg/mobilenativefoundation/store/cache5/CacheBuilder; - public final fun maximumSize (J)Lorg/mobilenativefoundation/store/cache5/CacheBuilder; - public final fun ticker (Lkotlin/jvm/functions/Function0;)Lorg/mobilenativefoundation/store/cache5/CacheBuilder; - public final fun weigher (JLkotlin/jvm/functions/Function2;)Lorg/mobilenativefoundation/store/cache5/CacheBuilder; -} - -public final class org/mobilenativefoundation/store/cache5/CacheBuilder$Companion { -} - -public final class org/mobilenativefoundation/store/cache5/StoreMultiCache : org/mobilenativefoundation/store/cache5/Cache { - public static final field Companion Lorg/mobilenativefoundation/store/cache5/StoreMultiCache$Companion; - public fun (Lorg/mobilenativefoundation/store/core5/KeyProvider;Lorg/mobilenativefoundation/store/cache5/Cache;Lorg/mobilenativefoundation/store/cache5/Cache;)V - public synthetic fun (Lorg/mobilenativefoundation/store/core5/KeyProvider;Lorg/mobilenativefoundation/store/cache5/Cache;Lorg/mobilenativefoundation/store/cache5/Cache;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun getAllPresent ()Ljava/util/Map; - public fun getAllPresent (Ljava/util/List;)Ljava/util/Map; - public synthetic fun getIfPresent (Ljava/lang/Object;)Ljava/lang/Object; - public fun getIfPresent (Lorg/mobilenativefoundation/store/core5/StoreKey;)Lorg/mobilenativefoundation/store/core5/StoreData; - public synthetic fun getOrPut (Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)Ljava/lang/Object; - public fun getOrPut (Lorg/mobilenativefoundation/store/core5/StoreKey;Lkotlin/jvm/functions/Function0;)Lorg/mobilenativefoundation/store/core5/StoreData; - public synthetic fun invalidate (Ljava/lang/Object;)V - public fun invalidate (Lorg/mobilenativefoundation/store/core5/StoreKey;)V - public fun invalidateAll ()V - public fun invalidateAll (Ljava/util/List;)V - public synthetic fun put (Ljava/lang/Object;Ljava/lang/Object;)V - public fun put (Lorg/mobilenativefoundation/store/core5/StoreKey;Lorg/mobilenativefoundation/store/core5/StoreData;)V - public fun putAll (Ljava/util/Map;)V - public fun size ()J -} - -public final class org/mobilenativefoundation/store/cache5/StoreMultiCache$Companion { - public final fun invalidKeyErrorMessage (Ljava/lang/Object;)Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/cache5/StoreMultiCacheAccessor { - public fun (Lorg/mobilenativefoundation/store/cache5/Cache;Lorg/mobilenativefoundation/store/cache5/Cache;)V - public final fun getAllPresent ()Ljava/util/Map; - public final fun getCollection (Lorg/mobilenativefoundation/store/core5/StoreKey$Collection;)Lorg/mobilenativefoundation/store/core5/StoreData$Collection; - public final fun getSingle (Lorg/mobilenativefoundation/store/core5/StoreKey$Single;)Lorg/mobilenativefoundation/store/core5/StoreData$Single; - public final fun invalidateAll ()V - public final fun invalidateCollection (Lorg/mobilenativefoundation/store/core5/StoreKey$Collection;)Z - public final fun invalidateSingle (Lorg/mobilenativefoundation/store/core5/StoreKey$Single;)Z - public final fun putCollection (Lorg/mobilenativefoundation/store/core5/StoreKey$Collection;Lorg/mobilenativefoundation/store/core5/StoreData$Collection;)Z - public final fun putSingle (Lorg/mobilenativefoundation/store/core5/StoreKey$Single;Lorg/mobilenativefoundation/store/core5/StoreData$Single;)Z - public final fun size ()J -} - diff --git a/cache/build.gradle.kts b/cache/build.gradle.kts deleted file mode 100644 index 40c462fc0..000000000 --- a/cache/build.gradle.kts +++ /dev/null @@ -1,22 +0,0 @@ -plugins { - id("org.mobilenativefoundation.store.multiplatform") -} - -kotlin { - - sourceSets { - commonMain { - dependencies { - api(libs.kotlinx.atomic.fu) - api(projects.core) - implementation(libs.kotlinx.coroutines.core) - } - } - commonTest { - dependencies { - implementation(libs.junit) - implementation(libs.kotlinx.coroutines.test) - } - } - } -} diff --git a/cache/config/ktlint/baseline.xml b/cache/config/ktlint/baseline.xml deleted file mode 100644 index 7d1ab2676..000000000 --- a/cache/config/ktlint/baseline.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/cache/gradle.properties b/cache/gradle.properties deleted file mode 100644 index ac546f2a1..000000000 --- a/cache/gradle.properties +++ /dev/null @@ -1,3 +0,0 @@ -POM_NAME=org.mobilenativefoundation.store -POM_ARTIFACT_ID=cache5 -POM_PACKAGING=jar \ No newline at end of file diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Cache.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Cache.kt deleted file mode 100644 index b2e89d042..000000000 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Cache.kt +++ /dev/null @@ -1,69 +0,0 @@ -package org.mobilenativefoundation.store.cache5 - -interface Cache { - /** - * @return [Value] associated with [key] or `null` if there is no cached value for [key]. - */ - fun getIfPresent(key: Key): Value? - - /** - * @return [Value] associated with [key], obtaining the value from [valueProducer] if necessary. - * No observable state associated with this cache is modified until loading completes. - * @param [valueProducer] Must not return `null`. It may either return a non-null value or throw an exception. - * @throws ExecutionExeption If a checked exception was thrown while loading the value. - * @throws UncheckedExecutionException If an unchecked exception was thrown while loading the value. - * @throws ExecutionError If an error was thrown while loading the value. - */ - fun getOrPut( - key: Key, - valueProducer: () -> Value, - ): Value - - /** - * @return Map of the [Value] associated with each [Key] in [keys]. Returned map only contains entries already present in the cache. - * The default implementation provided here throws a [NotImplementedError] to maintain backward compatibility for existing implementations. - */ - fun getAllPresent(keys: List<*>): Map - - /** - * @return Map of the [Value] associated with each [Key] in the cache. - */ - fun getAllPresent(): Map = throw NotImplementedError() - - /** - * Associates [value] with [key]. - * If the cache previously contained a value associated with [key], the old value is replaced by [value]. - * Prefer [getOrPut] when using the conventional "If cached, then return. Otherwise create, cache, and then return" pattern. - */ - fun put( - key: Key, - value: Value, - ) - - /** - * Copies all of the mappings from the specified map to the cache. The effect of this call is - * equivalent to that of calling [put] on this map once for each mapping from [Key] to [Value] in the specified map. - * The behavior of this operation is undefined if the specified map is modified while the operation is in progress. - */ - fun putAll(map: Map) - - /** - * Discards any cached value associated with [key]. - */ - fun invalidate(key: Key) - - /** - * Discards any cached value associated for [keys]. - */ - fun invalidateAll(keys: List) - - /** - * Discards all entries in the cache. - */ - fun invalidateAll() - - /** - * @return Approximate number of entries in the cache. - */ - fun size(): Long -} diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/CacheBuilder.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/CacheBuilder.kt deleted file mode 100644 index 9a0782da5..000000000 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/CacheBuilder.kt +++ /dev/null @@ -1,79 +0,0 @@ -package org.mobilenativefoundation.store.cache5 - -import kotlin.time.Duration - -class CacheBuilder { - internal var concurrencyLevel = 4 - private set - internal val initialCapacity = 16 - internal var maximumSize = UNSET - private set - internal var maximumWeight = UNSET - private set - internal var expireAfterAccess: Duration = Duration.INFINITE - private set - internal var expireAfterWrite: Duration = Duration.INFINITE - private set - internal var weigher: Weigher? = null - private set - internal var ticker: Ticker? = null - private set - - fun concurrencyLevel(producer: () -> Int): CacheBuilder = - apply { - concurrencyLevel = producer.invoke() - } - - fun maximumSize(maximumSize: Long): CacheBuilder = - apply { - if (maximumSize < 0) { - throw IllegalArgumentException("Maximum size must be non-negative.") - } - this.maximumSize = maximumSize - } - - fun expireAfterAccess(duration: Duration): CacheBuilder = - apply { - if (duration.isNegative()) { - throw IllegalArgumentException("Duration must be non-negative.") - } - expireAfterAccess = duration - } - - fun expireAfterWrite(duration: Duration): CacheBuilder = - apply { - if (duration.isNegative()) { - throw IllegalArgumentException("Duration must be non-negative.") - } - expireAfterWrite = duration - } - - fun ticker(ticker: Ticker): CacheBuilder = - apply { - this.ticker = ticker - } - - fun weigher( - maximumWeight: Long, - weigher: Weigher, - ): CacheBuilder = - apply { - if (maximumWeight < 0) { - throw IllegalArgumentException("Maximum weight must be non-negative.") - } - - this.maximumWeight = maximumWeight - this.weigher = weigher - } - - fun build(): Cache { - if (maximumSize != -1L && weigher != null) { - throw IllegalStateException("Maximum size cannot be combined with weigher.") - } - return LocalCache.LocalManualCache(this) - } - - companion object { - private const val UNSET = -1L - } -} diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/LocalCache.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/LocalCache.kt deleted file mode 100644 index a71382a9f..000000000 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/LocalCache.kt +++ /dev/null @@ -1,2082 +0,0 @@ -/* - * Copyright (C) 2009 The Guava Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - * KMP conversion - * Copyright (C) 2022 André Claßen - */ -package org.mobilenativefoundation.store.cache5 - -import kotlinx.atomicfu.AtomicArray -import kotlinx.atomicfu.AtomicRef -import kotlinx.atomicfu.atomic -import kotlinx.atomicfu.atomicArrayOfNulls -import kotlinx.atomicfu.locks.reentrantLock -import kotlinx.atomicfu.loop -import kotlin.math.min -import kotlin.time.Duration - -internal class LocalCache(builder: CacheBuilder) { - /** - * Mask value for indexing into segments. The upper bits of a key's hash code are used to choose - * the segment. - */ - private val segmentMask: Int - - /** - * Shift value for indexing within segments. Helps prevent entries that end up in the same segment - * from also ending up in the same bucket. - */ - private val segmentShift: Int - - /** - * The segments, each of which is a specialized hash table. - */ - - private val segments: Array?> - - /** - * Strategy for referencing values. - */ - private val valueStrength: Strength = Strength.Strong - - /** - * The maximum weight of this map. UNSET_LONG if there is no maximum. - */ - private val maxWeight: Long - - /** - * Weigher to weigh cache entries. - */ - private val weigher: Weigher - - /** - * How long after the last access to an entry the map will retain that entry. - */ - private val expireAfterAccessNanos: Long - - /** - * How long after the last write to an entry the map will retain that entry. - */ - private val expireAfterWriteNanos: Long - - /** - * Measures time in a testable way. - */ - private val ticker: Ticker - - /** - * Factory used to create new entries. - */ - private val entryFactory: EntryFactory - - private val evictsBySize: Boolean get() = maxWeight >= 0 - - private val customWeigher: Boolean get() = weigher !== OneWeigher - - private val expiresAfterWrite: Boolean get() = expireAfterWriteNanos > 0 - - private val expiresAfterAccess: Boolean get() = expireAfterAccessNanos > 0 - - private val usesAccessQueue: Boolean get() = expiresAfterAccess || evictsBySize - - private val usesWriteQueue: Boolean get() = expiresAfterWrite - - private val recordsWrite: Boolean get() = expiresAfterWrite - - private val recordsAccess: Boolean get() = expiresAfterAccess - - private val recordsTime: Boolean get() = recordsWrite || recordsAccess - - private val usesWriteEntries: Boolean get() = usesWriteQueue || recordsWrite - - private val usesAccessEntries: Boolean get() = usesAccessQueue || recordsAccess - - private sealed class Strength { - /* - * TODO(kevinb): If we strongly reference the value and aren't loading, we needn't wrap the - * value. This could save ~8 bytes per entry. - */ - object Strong : Strength() { - override fun referenceValue( - segment: Segment?, - entry: ReferenceEntry?, - value: V, - weight: Int, - ): ValueReference { - return if (weight == 1) { - StrongValueReference(value) - } else { - WeightedStrongValueReference( - value, - weight, - ) - } - } - } - - /** - * Creates a reference for the given value according to this value strength. - */ - abstract fun referenceValue( - segment: Segment?, - entry: ReferenceEntry?, - value: V, - weight: Int, - ): ValueReference - } - - /** - * Creates new entries. - */ - private sealed class EntryFactory { - object Strong : EntryFactory() { - override fun newEntry( - segment: Segment?, - key: K, - hash: Int, - next: ReferenceEntry?, - ): ReferenceEntry { - return StrongEntry(key, hash, next) - } - } - - object StrongAccess : EntryFactory() { - override fun newEntry( - segment: Segment?, - key: K, - hash: Int, - next: ReferenceEntry?, - ): ReferenceEntry { - return StrongAccessEntry(key, hash, next) - } - - override fun copyEntry( - segment: Segment?, - original: ReferenceEntry, - newNext: ReferenceEntry?, - ): ReferenceEntry { - val newEntry = super.copyEntry(segment, original, newNext) - copyAccessEntry(original, newEntry) - return newEntry - } - } - - object StrongWrite : EntryFactory() { - override fun newEntry( - segment: Segment?, - key: K, - hash: Int, - next: ReferenceEntry?, - ): ReferenceEntry { - return StrongWriteEntry(key, hash, next) - } - - override fun copyEntry( - segment: Segment?, - original: ReferenceEntry, - newNext: ReferenceEntry?, - ): ReferenceEntry { - val newEntry = super.copyEntry(segment, original, newNext) - copyWriteEntry(original, newEntry) - return newEntry - } - } - - object StrongAccessWrite : EntryFactory() { - override fun newEntry( - segment: Segment?, - key: K, - hash: Int, - next: ReferenceEntry?, - ): ReferenceEntry { - return StrongAccessWriteEntry(key, hash, next) - } - - override fun copyEntry( - segment: Segment?, - original: ReferenceEntry, - newNext: ReferenceEntry?, - ): ReferenceEntry { - val newEntry = super.copyEntry(segment, original, newNext) - copyAccessEntry(original, newEntry) - copyWriteEntry(original, newEntry) - return newEntry - } - } - - /** - * Creates a new entry. - * - * @param segment to create the entry for - * @param key of the entry - * @param hash of the key - * @param next entry in the same bucket - */ - abstract fun newEntry( - segment: Segment?, - key: K, - hash: Int, - next: ReferenceEntry?, - ): ReferenceEntry - - /** - * Copies an entry, assigning it a new `next` entry. - * - * @param original the entry to copy - * @param newNext entry in the same bucket - */ - // Guarded By Segment.this - open fun copyEntry( - segment: Segment?, - original: ReferenceEntry, - newNext: ReferenceEntry?, - ): ReferenceEntry { - return newEntry(segment, original.key, original.hash, newNext) - } - - // Guarded By Segment.this - fun copyAccessEntry( - original: ReferenceEntry, - newEntry: ReferenceEntry, - ) { - // TODO(fry): when we link values instead of entries this method can go - // away, as can connectAccessOrder, nullifyAccessOrder. - newEntry.accessTime = original.accessTime - connectAccessOrder(original.previousInAccessQueue, newEntry) - connectAccessOrder(newEntry, original.nextInAccessQueue) - nullifyAccessOrder(original) - } - - // Guarded By Segment.this - fun copyWriteEntry( - original: ReferenceEntry, - newEntry: ReferenceEntry, - ) { - // TODO(fry): when we link values instead of entries this method can go - // away, as can connectWriteOrder, nullifyWriteOrder. - newEntry.writeTime = original.writeTime - connectWriteOrder(original.previousInWriteQueue, newEntry) - connectWriteOrder(newEntry, original.nextInWriteQueue) - nullifyWriteOrder(original) - } - - companion object { - /** - * Masks used to compute indices in the following table. - */ - private const val ACCESS_MASK = 1 - private const val WRITE_MASK = 2 - - /** - * Look-up table for factories. - */ - private val factories = arrayOf(Strong, StrongAccess, StrongWrite, StrongAccessWrite) - - fun getFactory( - usesAccessQueue: Boolean, - usesWriteQueue: Boolean, - ): EntryFactory { - val flags = ((if (usesAccessQueue) ACCESS_MASK else 0) or if (usesWriteQueue) WRITE_MASK else 0) - return factories[flags] - } - } - } - - /** - * A reference to a value. - */ - private interface ValueReference { - /** - * Returns the value. Does not block or throw exceptions. - */ - fun get(): V? - - /** - * Returns the weight of this entry. This is assumed to be static between calls to setValue. - */ - val weight: Int - - /** - * Returns the entry associated with this value reference, or `null` if this value - * reference is independent of any entry. - */ - val entry: ReferenceEntry? - - /** - * Creates a copy of this reference for the given entry. - * - * - * - * `value` may be null only for a loading reference. - */ - - fun copyFor( - value: V?, - entry: ReferenceEntry?, - ): ValueReference - - /** - * Notifify pending loads that a new value was set. This is only relevant to loading - * value references. - */ - fun notifyNewValue(newValue: V) - - /** - * Returns true if this reference contains an active value, meaning one that is still considered - * present in the cache. Active values consist of live values, which are returned by cache - * lookups, and dead values, which have been evicted but awaiting removal. Non-active values - * consist strictly of loading values, though during refresh a value may be both active and - * loading. - */ - val isActive: Boolean - } - - /** - * An entry in a reference map. - * - * - * Entries in the map can be in the following states: - * - * - * Valid: - * - Live: valid key/value are set - * - Loading: loading is pending - * - * - * Invalid: - * - Expired: time expired (key/value may still be set) - * - Collected: key/value was partially collected, but not yet cleaned up - * - Unset: marked as unset, awaiting cleanup or reuse - */ - private interface ReferenceEntry { - /** - * Returns the value reference from this entry. - */ - /** - * Sets the value reference for this entry. - */ - var valueReference: ValueReference? - get() = throw UnsupportedOperationException() - set(_) = throw UnsupportedOperationException() - - /** - * Returns the next entry in the chain. - */ - val next: ReferenceEntry? - get() = throw UnsupportedOperationException() - - /** - * Returns the entry's hash. - */ - val hash: Int - get() = throw UnsupportedOperationException() - - /** - * Returns the key for this entry. - */ - val key: K - get() = throw UnsupportedOperationException() - /* - * Used by entries that use access order. Access entries are maintained in a doubly-linked list. - * New entries are added at the tail of the list at write time; stale entries are expired from - * the head of the list. - */ - /** - * Returns the time that this entry was last accessed, in ns. - */ - /** - * Sets the entry access time in ns. - */ - var accessTime: Long - get() = throw UnsupportedOperationException() - set(_) = throw UnsupportedOperationException() - /** - * Returns the next entry in the access queue. - */ - /** - * Sets the next entry in the access queue. - */ - var nextInAccessQueue: ReferenceEntry - get() = throw UnsupportedOperationException() - set(_) = throw UnsupportedOperationException() - /** - * Returns the previous entry in the access queue. - */ - /** - * Sets the previous entry in the access queue. - */ - var previousInAccessQueue: ReferenceEntry - get() = throw UnsupportedOperationException() - set(_) = throw UnsupportedOperationException() - /* - * Implemented by entries that use write order. Write entries are maintained in a - * doubly-linked list. New entries are added at the tail of the list at write time and stale - * entries are expired from the head of the list. - */ - /** - * Returns the time that this entry was last written, in ns. - */ - /** - * Sets the entry write time in ns. - */ - var writeTime: Long - get() = throw UnsupportedOperationException() - set(_) = throw UnsupportedOperationException() - /** - * Returns the next entry in the write queue. - */ - /** - * Sets the next entry in the write queue. - */ - var nextInWriteQueue: ReferenceEntry - get() = throw UnsupportedOperationException() - set(_) = throw UnsupportedOperationException() - /** - * Returns the previous entry in the write queue. - */ - /** - * Sets the previous entry in the write queue. - */ - var previousInWriteQueue: ReferenceEntry - get() = throw UnsupportedOperationException() - set(_) = throw UnsupportedOperationException() - } - - private object NullEntry : ReferenceEntry { - override var valueReference: ValueReference? - get() = null - set(_) {} - - override val next: ReferenceEntry? - get() = null - - override val hash: Int - get() = 0 - - override val key: Any - get() = Unit - - override var accessTime: Long - get() = 0 - set(_) {} - - override var nextInAccessQueue: ReferenceEntry - get() = this - set(_) {} - - override var previousInAccessQueue: ReferenceEntry - get() = this - set(_) {} - - override var writeTime: Long - get() = 0 - set(_) {} - - override var nextInWriteQueue: ReferenceEntry - get() = this - set(_) {} - - override var previousInWriteQueue: ReferenceEntry - get() = this - set(_) {} - } - - /* - * Note: All of this duplicate code sucks, but it saves a lot of memory. If only Java had mixins! - * To maintain this code, make a change for the strong reference type. Then, cut and paste, and - * replace "Strong" with "Soft" or "Weak" within the pasted text. The primary difference is that - * strong entries store the key reference directly while soft and weak entries delegate to their - * respective superclasses. - */ - - /** - * Used for strongly-referenced keys. - */ - private open class StrongEntry( - override val key: K, // The code below is exactly the same for each entry type. - override val hash: Int, - override val next: ReferenceEntry?, - ) : ReferenceEntry { - private val _valueReference = atomic?>(unset()) - override var valueReference: ValueReference? = _valueReference.value - } - - private class StrongAccessEntry( - key: K, - hash: Int, - next: ReferenceEntry?, - ) : - StrongEntry(key, hash, next) { - // The code below is exactly the same for each access entry type. - - private val _accessTime = atomic(Long.MAX_VALUE) - override var accessTime = _accessTime.value - - // Guarded By Segment.this - override var nextInAccessQueue: ReferenceEntry = nullEntry() - - // Guarded By Segment.this - override var previousInAccessQueue: ReferenceEntry = nullEntry() - } - - private class StrongWriteEntry( - key: K, - hash: Int, - next: ReferenceEntry?, - ) : - StrongEntry(key, hash, next) { - // The code below is exactly the same for each write entry type. - private val _writeTime = atomic(Long.MAX_VALUE) - override var writeTime = _writeTime.value - - // Guarded By Segment.this - override var nextInWriteQueue: ReferenceEntry = nullEntry() - - // Guarded By Segment.this - override var previousInWriteQueue: ReferenceEntry = nullEntry() - } - - private class StrongAccessWriteEntry( - key: K, - hash: Int, - next: ReferenceEntry?, - ) : - StrongEntry(key, hash, next) { - // The code below is exactly the same for each access entry type. - private val _accessTime = atomic(Long.MAX_VALUE) - override var accessTime: Long = _accessTime.value - - // Guarded By Segment.this - override var nextInAccessQueue: ReferenceEntry = nullEntry() - - // Guarded By Segment.this - override var previousInAccessQueue: ReferenceEntry = nullEntry() - - // The code below is exactly the same for each write entry type. - private val _writeTime = atomic(Long.MAX_VALUE) - override var writeTime: Long = _writeTime.value - - // Guarded By Segment.this - override var nextInWriteQueue: ReferenceEntry = nullEntry() - - // Guarded By Segment.this - override var previousInWriteQueue: ReferenceEntry = nullEntry() - } - - /** - * References a strong value. - */ - private open class StrongValueReference(private val referent: V) : - ValueReference { - override fun get(): V = referent - - override val weight: Int = 1 - override val entry: ReferenceEntry? = null - - override fun copyFor( - value: V?, - entry: ReferenceEntry?, - ): ValueReference = this - - override val isActive: Boolean = true - - override fun notifyNewValue(newValue: V) {} - } - - /** - * References a strong value. - */ - private class WeightedStrongValueReference( - referent: V, - override val weight: Int, - ) : - StrongValueReference(referent) - - /** - * This method is a convenience for testing. Code should call [Segment.newEntry] directly. - */ - - private fun newEntry( - key: K, - hash: Int, - next: ReferenceEntry?, - ): ReferenceEntry { - val segment = segmentFor(hash) - segment.reentrantLock.lock() - return try { - segment.newEntry(key, hash, next) - } finally { - segment.reentrantLock.unlock() - } - } - - /** - * This method is a convenience for testing. Code should call [Segment.copyEntry] directly. - */ - // Guarded By Segment.this - private fun copyEntry( - original: ReferenceEntry, - newNext: ReferenceEntry?, - ): ReferenceEntry? { - val hash = original.hash - return segmentFor(hash).copyEntry(original, newNext) - } - - /** - * This method is a convenience for testing. Code should call [Segment.setValue] instead. - */ - // Guarded By Segment.this - private fun newValueReference( - entry: ReferenceEntry, - value: V, - weight: Int, - ): ValueReference { - val hash = entry.hash - return valueStrength.referenceValue(segmentFor(hash), entry, value, weight) - } - - private fun hash(key: K): Int = rehash(key.hashCode()) - - /** - * Returns the segment that should be used for a key with the given hash. - * - * @param hash the hash code for the key - * @return the segment - */ - private fun segmentFor(hash: Int): Segment = - // TODO(fry): Lazily create segments? - segments[hash ushr segmentShift and segmentMask] as Segment - - private fun createSegment( - initialCapacity: Int, - maxSegmentWeight: Long, - ): Segment = Segment(this, initialCapacity, maxSegmentWeight) - // expiration - - /** - * Returns true if the entry has expired. - */ - private fun isExpired( - entry: ReferenceEntry, - now: Long, - ): Boolean = - if (expiresAfterAccess && now - entry.accessTime >= expireAfterAccessNanos) { - true - } else { - expiresAfterWrite && now - entry.writeTime >= expireAfterWriteNanos - } - - // Inner Classes - - private class SegmentTable(val size: Int) { - private val table: AtomicArray?> = atomicArrayOfNulls(size) - - operator fun get(idx: Int) = table[idx].value - - operator fun set( - idx: Int, - value: ReferenceEntry?, - ) { - table[idx].value = value - } - } - - /** - * Segments are specialized versions of hash tables. - */ - private class Segment( - private val map: LocalCache, - initialCapacity: Int, - private val maxSegmentWeight: Long, - ) { - /* - * TODO(fry): Consider copying variables (like evictsBySize) from outer class into this class. - * It will require more memory but will reduce indirection. - */ - /* - * Segments maintain a table of entry lists that are ALWAYS kept in a consistent state, so can - * be read without locking. Next fields of nodes are immutable (final). All list additions are - * performed at the front of each bin. This makes it easy to check changes, and also fast to - * traverse. When nodes would otherwise be changed, new nodes are created to replace them. This - * works well for hash tables since the bin lists tend to be short. (The average length is less - * than two.) - * - * Read operations can thus proceed without locking, but rely on selected uses of volatiles to - * ensure that completed write operations performed by other threads are noticed. For most - * purposes, the "count" field, tracking the number of elements, serves as that volatile - * variable ensuring visibility. This is convenient because this field needs to be read in many - * read operations anyway: - * - * - All (unsynchronized) read operations must first read the "count" field, and should not - * look at table entries if it is 0. - * - * - All (synchronized) write operations should write to the "count" field after structurally - * changing any bin. The operations must not take any action that could even momentarily - * cause a concurrent read operation to see inconsistent data. This is made easier by the - * nature of the read operations in Map. For example, no operation can reveal that the table - * has grown but the threshold has not yet been updated, so there are no atomicity requirements - * for this with respect to reads. - * - * As a guide, all critical volatile reads and writes to the count field are marked in code - * comments. - */ - - val reentrantLock = reentrantLock() - - /** - * The number of live elements in this segment's region. - */ - private val count = atomic(0) - - /** - * The weight of the live elements in this segment's region. - */ - private var totalWeight: Long = 0 - - /** - * Number of updates that alter the size of the table. This is used during bulk-read methods to - * make sure they see a consistent snapshot: If modCounts change during a traversal of segments - * loading size or checking containsValue, then we might have an inconsistent view of state - * so (usually) must retry. - */ - private var modCount = 0 - - /** - * The table is expanded when its size exceeds this threshold. (The value of this field is - * always `(int) (capacity * 0.75)`.) - */ - private var threshold = 0 - - /** - * The per-segment table. - */ - private val table: AtomicRef> - - /** - * The recency queue is used to record which entries were accessed for updating the access - * list's ordering. It is drained as a batch operation when either the DRAIN_THRESHOLD is - * crossed or a write occurs on the segment. - */ - private val recencyQueue: Queue> - - /** - * A counter of the number of reads since the last write, used to drain queues on a small - * fraction of read operations. - */ - private val readCount = atomic(0) - - /** - * A queue of elements currently in the map, ordered by write time. Elements are added to the - * tail of the queue on write. - */ - private val writeQueue: MutableQueue> - - /** - * A queue of elements currently in the map, ordered by access time. Elements are added to the - * tail of the queue on access (note that writes count as accesses). - */ - private val accessQueue: MutableQueue> - - fun newEntry( - key: K, - hash: Int, - next: ReferenceEntry?, - ): ReferenceEntry = map.entryFactory.newEntry(this, key, hash, next) - - /** - * Copies `original` into a new entry chained to `newNext`. Returns the new entry, - * or `null` if `original` was already garbage collected. - */ - fun copyEntry( - original: ReferenceEntry, - newNext: ReferenceEntry?, - ): ReferenceEntry? { - val valueReference = original.valueReference - val value = valueReference!!.get() - if (value == null && valueReference.isActive) { - // value collected - return null - } - val newEntry = map.entryFactory.copyEntry(this, original, newNext) - newEntry.valueReference = valueReference.copyFor(value, newEntry) - return newEntry - } - - /** - * Sets a new value of an entry. Adds newly created entries at the end of the access queue. - */ - fun setValue( - entry: ReferenceEntry, - key: K, - value: V, - now: Long, - ) { - val previous = entry.valueReference - val weight = map.weigher(key, value) - if (weight < 0) throw IllegalStateException("Weights must be non-negative") - entry.valueReference = map.valueStrength.referenceValue(this, entry, value, weight) - recordWrite(entry, weight, now) - previous?.notifyNewValue(value) - } - - // recency queue, shared by expiration and eviction - - /** - * Records the relative order in which this read was performed by adding `entry` to the - * recency queue. At write-time, or when the queue is full past the threshold, the queue will - * be drained and the entries therein processed. - * - * - * - * Note: locked reads should use [.recordLockedRead]. - */ - private fun recordRead( - entry: ReferenceEntry, - now: Long, - ) { - if (map.recordsAccess) { - entry.accessTime = now - } - recencyQueue.add(entry) - } - - /** - * Updates the eviction metadata that `entry` was just read. This currently amounts to - * adding `entry` to relevant eviction lists. - * - * - * - * Note: this method should only be called under lock, as it directly manipulates the - * eviction queues. Unlocked reads should use [.recordRead]. - */ - private fun recordLockedRead( - entry: ReferenceEntry, - now: Long, - ) { - if (map.recordsAccess) { - entry.accessTime = now - } - accessQueue.add(entry) - } - - /** - * Updates eviction metadata that `entry` was just written. This currently amounts to - * adding `entry` to relevant eviction lists. - */ - private fun recordWrite( - entry: ReferenceEntry, - weight: Int, - now: Long, - ) { - // we are already under lock, so drain the recency queue immediately - drainRecencyQueue() - totalWeight += weight.toLong() - if (map.recordsAccess) { - entry.accessTime = now - } - if (map.recordsWrite) { - entry.writeTime = now - } - accessQueue.add(entry) - writeQueue.add(entry) - } - - /** - * Drains the recency queue, updating eviction metadata that the entries therein were read in - * the specified relative order. This currently amounts to adding them to relevant eviction - * lists (accounting for the fact that they could have been removed from the map since being - * added to the recency queue). - */ - private fun drainRecencyQueue() { - while (true) { - val e = recencyQueue.poll() ?: break - // An entry may be in the recency queue despite it being removed from - // the map . This can occur when the entry was concurrently read while a - // writer is removing it from the segment or after a clear has removed - // all of the segment's entries. - if (accessQueue.contains(e)) { - accessQueue.add(e) - } - } - } - // expiration - - /** - * Cleanup expired entries when the lock is available. - */ - private fun tryExpireEntries(now: Long) { - if (reentrantLock.tryLock()) { - try { - expireEntries(now) - } finally { - reentrantLock.unlock() - // don't call postWriteCleanup as we're in a read - } - } - } - - private fun expireEntries(now: Long) { - drainRecencyQueue() - while (true) { - val e = writeQueue.peek()?.takeIf { map.isExpired(it, now) } ?: break - if (!removeEntry(e, e.hash, RemovalCause.EXPIRED)) { - throw AssertionError() - } - } - - while (true) { - val e = accessQueue.peek()?.takeIf { map.isExpired(it, now) } ?: break - if (!removeEntry(e, e.hash, RemovalCause.EXPIRED)) { - throw AssertionError() - } - } - } - - // eviction - private fun enqueueNotification( - entry: ReferenceEntry, - cause: RemovalCause?, - ) { - enqueueNotification(entry.key, entry.hash, entry.valueReference, cause) - } - - private fun enqueueNotification( - key: K?, - hash: Int, - valueReference: ValueReference?, - cause: RemovalCause?, - ) { - valueReference?.weight?.toLong()?.apply { - totalWeight -= this - } - } - - /** - * Performs eviction if the segment is over capacity. Avoids flushing the entire cache if the - * newest entry exceeds the maximum weight all on its own. - * - * @param newest the most recently added entry - */ - private fun evictEntries(newest: ReferenceEntry) { - if (!map.evictsBySize) { - return - } - drainRecencyQueue() - - // If the newest entry by itself is too heavy for the segment, don't bother evicting - // anything else, just that - if (newest.valueReference!!.weight > maxSegmentWeight) { - if (!removeEntry(newest, newest.hash, RemovalCause.SIZE)) { - throw AssertionError() - } - } - while (totalWeight > maxSegmentWeight) { - val e = nextEvictable - if (!removeEntry(e, e.hash, RemovalCause.SIZE)) { - throw AssertionError() - } - } - } - - // TODO(fry): instead implement this with an eviction head - - private val nextEvictable: ReferenceEntry - get() { - for (e in accessQueue) { - val weight = e.valueReference!!.weight - if (weight > 0) { - return e - } - } - throw AssertionError() - } - - /** - * Returns first entry of bin for given hash. - */ - private fun getFirst(hash: Int): ReferenceEntry? { - // read this volatile field only once - val table = table.value - return table[hash and table.size - 1] - } - - // Specialized implementations of map methods - private fun getEntry( - key: K, - hash: Int, - ): ReferenceEntry? { - var e = getFirst(hash) - while (e != null) { - if (e.hash != hash) { - e = e.next - continue - } - val entryKey = e.key - if (key == entryKey) { - return e - } - e = e.next - } - return null - } - - private fun getLiveEntry( - key: K, - hash: Int, - now: Long, - ): ReferenceEntry? { - val e = getEntry(key, hash) - if (e == null) { - return null - } else if (map.isExpired(e, now)) { - tryExpireEntries(now) - return null - } - return e - } - - /** - * Gets the value from an entry. Returns null if the entry is invalid, partially-collected, - * loading, or expired. - */ - - fun get( - key: K, - hash: Int, - ): V? { - return try { - if (count.value != 0) { // read-volatile - val now = map.ticker() - val e = getLiveEntry(key, hash, now) ?: return null - val value = e.valueReference?.get() - if (value != null) { - recordRead(e, now) - return value - } - } - null - } finally { - postReadCleanup() - } - } - - fun getOrPut( - key: K, - hash: Int, - defaultValue: () -> V, - ): V { - reentrantLock.lock() - return try { - if (count.value != 0) { // read-volatile - val now = map.ticker() - val e = getLiveEntry(key, hash, now) - val value = e?.valueReference?.get() - if (value != null) { - recordRead(e, now) - return value - } - } - val default = defaultValue() - put(key, hash, default, false) - default - } finally { - reentrantLock.unlock() - postReadCleanup() - } - } - - fun put( - key: K, - hash: Int, - value: V, - onlyIfAbsent: Boolean, - ): V? { - reentrantLock.lock() - return try { - val now = map.ticker() - preWriteCleanup(now) - if (count.value + 1 > threshold) { // ensure capacity - expand() - } - val table = table.value - val index = hash and table.size - 1 - val first = table[index] - - // Look for an existing entry. - var e: ReferenceEntry? = first - while (e != null) { - val entryKey = e.key - if (e.hash == hash && key == entryKey) { - // We found an existing entry. - val valueReference = e.valueReference - val entryValue = valueReference!!.get() - return when { - entryValue == null -> { - ++modCount - val newCount = - if (valueReference.isActive) { - enqueueNotification( - key, - hash, - valueReference, - RemovalCause.COLLECTED, - ) - setValue(e, key, value, now) - count.value // count remains unchanged - } else { - setValue(e, key, value, now) - count.value + 1 - } - count.value = newCount // write-volatile - evictEntries(e) - null - } - - onlyIfAbsent -> { - // Mimic - // "if (!map.containsKey(key)) ... - // else return map.get(key); - recordLockedRead(e, now) - entryValue - } - - else -> { - // clobber existing entry, count remains unchanged - ++modCount - enqueueNotification( - key, - hash, - valueReference, - RemovalCause.REPLACED, - ) - setValue(e, key, value, now) - evictEntries(e) - entryValue - } - } - } - e = e.next - } - - // Create a new entry. - ++modCount - val newEntry = newEntry(key, hash, first) - setValue(newEntry, key, value, now) - table[index] = newEntry - count.plusAssign(1) - evictEntries(newEntry) - null - } finally { - reentrantLock.unlock() - postWriteCleanup() - } - } - - fun remove( - key: K, - hash: Int, - ): V? { - reentrantLock.lock() - return try { - val now = map.ticker() - preWriteCleanup(now) - val table = table.value - val index = hash and table.size - 1 - val first = table[index] - var e = first - while (e != null) { - val entryKey = e.key - if (e.hash == hash && key == entryKey) { - val valueReference = e.valueReference - val entryValue = valueReference!!.get() - val cause: RemovalCause = - when { - entryValue != null -> { - RemovalCause.EXPLICIT - } - - valueReference.isActive -> { - RemovalCause.COLLECTED - } - - else -> { - // currently loading - return null - } - } - ++modCount - val newFirst = - removeValueFromChain( - first!!, - e, - entryKey, - hash, - valueReference, - cause, - ) - val newCount = count.value - 1 - table[index] = newFirst - count.value = newCount // write-volatile - return entryValue - } - e = e.next - } - null - } finally { - reentrantLock.unlock() - postWriteCleanup() - } - } - - fun clear() { - if (count.value != 0) { // read-volatile - reentrantLock.lock() - try { - val table = table.value - for (i in 0 until table.size) { - var e = table[i] - while (e != null) { - // Loading references aren't actually in the map yet. - if (e.valueReference!!.isActive) { - enqueueNotification(e, RemovalCause.EXPLICIT) - } - e = e.next - } - } - for (i in 0 until table.size) { - table[i] = null - } - writeQueue.clear() - accessQueue.clear() - readCount.value = 0 - ++modCount - count.value = 0 // write-volatile - } finally { - reentrantLock.unlock() - postWriteCleanup() - } - } - } - - /** - * Expands the table if possible. - */ - private fun expand() { - val oldTable = table.value - val oldCapacity = oldTable.size - if (oldCapacity >= MAXIMUM_CAPACITY) { - return - } - - /* - * Reclassify nodes in each list to new Map. Because we are using power-of-two expansion, the - * elements from each bin must either stay at same index, or move with a power of two offset. - * We eliminate unnecessary node creation by catching cases where old nodes can be reused - * because their next fields won't change. Statistically, at the default threshold, only - * about one-sixth of them need cloning when a table doubles. The nodes they replace will be - * garbage collectable as soon as they are no longer referenced by any reader thread that may - * be in the midst of traversing table right now. - */ - var newCount = count.value - val newTable = SegmentTable(oldCapacity shl 1) - threshold = newTable.size * 3 / 4 - val newMask = newTable.size - 1 - for (oldIndex in 0 until oldCapacity) { - // We need to guarantee that any existing reads of old Map can - // proceed. So we cannot yet null out each bin. - val head = oldTable[oldIndex] ?: continue - - val next = head.next - val headIndex = head.hash and newMask - - // Single node on list - if (next == null) { - newTable[headIndex] = head - } else { - // Reuse the consecutive sequence of nodes with the same target - // index from the end of the list. tail points to the first - // entry in the reusable list. - var tail = head - var tailIndex = headIndex - var entry = next - while (entry != null) { - val newIndex = entry.hash and newMask - if (newIndex != tailIndex) { - // The index changed. We'll need to copy the previous entry. - tailIndex = newIndex - tail = entry - } - entry = entry.next - } - newTable[tailIndex] = tail - - // Clone nodes leading up to the tail. - var headEntry = head - while (headEntry !== tail) { - val newIndex = headEntry.hash and newMask - val newNext = newTable[newIndex] - val newFirst = copyEntry(headEntry, newNext) - if (newFirst != null) { - newTable[newIndex] = newFirst - } else { - removeCollectedEntry(headEntry) - newCount-- - } - headEntry = headEntry.next ?: break - } - } - } - table.value = newTable - count.value = newCount - } - - private fun removeValueFromChain( - first: ReferenceEntry, - entry: ReferenceEntry, - key: K, - hash: Int, - valueReference: ValueReference, - cause: RemovalCause?, - ): ReferenceEntry? { - enqueueNotification(key, hash, valueReference, cause) - writeQueue.remove(entry) - accessQueue.remove(entry) - return removeEntryFromChain(first, entry) - } - - private fun removeEntryFromChain( - first: ReferenceEntry, - entry: ReferenceEntry, - ): ReferenceEntry? { - var newCount = count.value - var newFirst = entry.next - var e = first - while (e !== entry) { - val next = copyEntry(e, newFirst) - if (next != null) { - newFirst = next - } else { - removeCollectedEntry(e) - newCount-- - } - e = e.next ?: break - } - count.value = newCount - return newFirst - } - - private fun removeCollectedEntry(entry: ReferenceEntry) { - enqueueNotification(entry, RemovalCause.COLLECTED) - writeQueue.remove(entry) - accessQueue.remove(entry) - } - - private fun removeEntry( - entry: ReferenceEntry, - hash: Int, - cause: RemovalCause?, - ): Boolean { - val table = table.value - val index = hash and table.size - 1 - val first = table[index] - var e = first - - while (e != null) { - if (e === entry) { - ++modCount - val newFirst = - removeValueFromChain( - first!!, - e, - e.key, - hash, - e.valueReference!!, - cause, - ) - val newCount = count.value - 1 - table[index] = newFirst - count.value = newCount // write-volatile - return true - } - e = e.next - } - return false - } - - /** - * Performs routine cleanup following a read. Normally cleanup happens during writes. If cleanup - * is not observed after a sufficient number of reads, try cleaning up from the read thread. - */ - private fun postReadCleanup() { - if (readCount.incrementAndGet() and DRAIN_THRESHOLD == 0) { - cleanUp() - } - } - - /** - * Performs routine cleanup prior to executing a write. This should be called every time a - * write thread acquires the segment lock, immediately after acquiring the lock. - * - * - * - * Post-condition: expireEntries has been run. - */ - private fun preWriteCleanup(now: Long) { - runLockedCleanup(now) - } - - /** - * Performs routine cleanup following a write. - */ - private fun postWriteCleanup() { - runUnlockedCleanup() - } - - fun cleanUp() { - val now = map.ticker() - runLockedCleanup(now) - runUnlockedCleanup() - } - - private fun runLockedCleanup(now: Long) { - if (reentrantLock.tryLock()) { - try { - expireEntries(now) // calls drainRecencyQueue - readCount.value = 0 - } finally { - reentrantLock.unlock() - } - } - } - - private fun runUnlockedCleanup() { - // locked cleanup may generate notifications we can send unlocked - /*if (!isHeldByCurrentThread) { - map.processPendingNotifications() - }*/ - } - - fun activeEntries(): Map { - // read-volatile - if (count.value == 0) return emptyMap() - reentrantLock.lock() - return try { - val activeMap = mutableMapOf() - val table = table.value - for (i in 0 until table.size) { - var e = table[i] - while (e != null) { - if (e.valueReference?.isActive == true) { - activeMap[e.key] = e.valueReference?.get()!! - } - e = e.next - } - } - activeMap.ifEmpty { emptyMap() } - } finally { - reentrantLock.unlock() - } - } - - init { - threshold = initialCapacity * 3 / 4 // 0.75 - if (!map.customWeigher && threshold.toLong() == maxSegmentWeight) { - // prevent spurious expansion before eviction - threshold++ - } - table = atomic(SegmentTable(initialCapacity)) - recencyQueue = if (map.usesAccessQueue) AtomicLinkedQueue() else discardingQueue() - writeQueue = if (map.usesWriteQueue) WriteQueue() else discardingQueue() - accessQueue = if (map.usesAccessQueue) AccessQueue() else discardingQueue() - } - } - // Queues - - private interface Queue { - fun poll(): T? - - fun add(value: T) - } - - private interface MutableQueue : Queue, Iterable { - fun peek(): E? - - fun isEmpty(): Boolean - - val size: Int - - fun clear() - - fun remove(element: E): Boolean - - fun contains(element: E): Boolean - } - - private class AtomicLinkedQueue : Queue { - private val head: AtomicRef> = atomic(Node(null)) - private val tail: AtomicRef> = atomic(head.value) - - private class Node(val value: T) { - val next = atomic?>(null) - } - - override fun add(value: T) { - val node: Node = Node(value) - tail.loop { curTail -> - val curNext = curTail.next.value - if (curNext != null) { - tail.compareAndSet(curTail, curNext) - return@loop - } - if (curTail.next.compareAndSet(null, node)) { - tail.compareAndSet(curTail, node) - return - } - } - } - - override fun poll(): T? { - head.loop { curHead -> - val next = curHead.next.value ?: return null - if (head.compareAndSet(curHead, next)) return next.value - } - } - } - - /** - * A custom queue for managing eviction order. Note that this is tightly integrated with `ReferenceEntry`, upon which it relies to perform its linking. - * - * - * - * Note that this entire implementation makes the assumption that all elements which are in - * the map are also in this queue, and that all elements not in the queue are not in the map. - * - * - * - * The benefits of creating our own queue are that (1) we can replace elements in the middle - * of the queue as part of copyWriteEntry, and (2) the contains method is highly optimized - * for the current model. - */ - - private class WriteQueue : MutableQueue> { - private val head: ReferenceEntry = - object : ReferenceEntry { - override var writeTime: Long - get() = Long.MAX_VALUE - set(_) {} - override var nextInWriteQueue: ReferenceEntry = this - override var previousInWriteQueue: ReferenceEntry = this - } - - // implements Queue - override fun add(value: ReferenceEntry) { - // unlink - connectWriteOrder(value.previousInWriteQueue, value.nextInWriteQueue) - - // add to tail - connectWriteOrder(head.previousInWriteQueue, value) - connectWriteOrder(value, head) - } - - override fun peek(): ReferenceEntry? { - val next = head.nextInWriteQueue - return if (next === head) null else next - } - - override fun poll(): ReferenceEntry? { - val next = head.nextInWriteQueue - if (next === head) { - return null - } - remove(next) - return next - } - - override fun remove(element: ReferenceEntry): Boolean { - val previous = element.previousInWriteQueue - val next = element.nextInWriteQueue - connectWriteOrder(previous, next) - nullifyWriteOrder(element) - return next !== NullEntry - } - - override fun contains(element: ReferenceEntry): Boolean = element.nextInWriteQueue !== NullEntry - - override fun isEmpty(): Boolean = head.nextInWriteQueue === head - - override val size: Int - get() { - var size = 0 - var e = head.nextInWriteQueue - while (e !== head) { - size++ - e = e.nextInWriteQueue - } - return size - } - - override fun clear() { - var e = head.nextInWriteQueue - while (e !== head) { - val next = e.nextInWriteQueue - nullifyWriteOrder(e) - e = next - } - head.nextInWriteQueue = head - head.previousInWriteQueue = head - } - - override fun iterator(): Iterator> = - iterator { - var value = peek() - while (value != null) { - yield(value) - val next = value.nextInWriteQueue - value = if (next === head) null else next - } - } - } - - /** - * A custom queue for managing access order. Note that this is tightly integrated with - * `ReferenceEntry`, upon which it reliese to perform its linking. - * - * - * - * Note that this entire implementation makes the assumption that all elements which are in - * the map are also in this queue, and that all elements not in the queue are not in the map. - * - * - * - * The benefits of creating our own queue are that (1) we can replace elements in the middle - * of the queue as part of copyWriteEntry, and (2) the contains method is highly optimized - * for the current model. - */ - private class AccessQueue : MutableQueue> { - private val head: ReferenceEntry = - object : ReferenceEntry { - override var accessTime: Long - get() = Long.MAX_VALUE - set(_) {} - override var nextInAccessQueue: ReferenceEntry = this - override var previousInAccessQueue: ReferenceEntry = this - } - - // implements Queue - override fun add(value: ReferenceEntry) { - // unlink - connectAccessOrder(value.previousInAccessQueue, value.nextInAccessQueue) - - // add to tail - connectAccessOrder(head.previousInAccessQueue, value) - connectAccessOrder(value, head) - } - - override fun peek(): ReferenceEntry? { - val next = head.nextInAccessQueue - return if (next === head) null else next - } - - override fun poll(): ReferenceEntry? { - val next = head.nextInAccessQueue - if (next === head) { - return null - } - remove(next) - return next - } - - override fun remove(element: ReferenceEntry): Boolean { - val previous = element.previousInAccessQueue - val next = element.nextInAccessQueue - connectAccessOrder(previous, next) - nullifyAccessOrder(element) - return next !== NullEntry - } - - override fun contains(element: ReferenceEntry): Boolean = element.nextInAccessQueue !== NullEntry - - override fun isEmpty(): Boolean = head.nextInAccessQueue === head - - override val size: Int - get() { - var size = 0 - var e = head.nextInAccessQueue - while (e !== head) { - size++ - e = e.nextInAccessQueue - } - return size - } - - override fun clear() { - var e = head.nextInAccessQueue - while (e !== head) { - val next = e.nextInAccessQueue - nullifyAccessOrder(e) - e = next - } - head.nextInAccessQueue = head - head.previousInAccessQueue = head - } - - override fun iterator(): Iterator> = - iterator { - var value = peek() - while (value != null) { - yield(value) - val next = value.nextInAccessQueue - value = if (next === head) null else next - } - } - } - - // Cache support - fun cleanUp() { - for (segment in segments) { - segment?.cleanUp() - } - } - - // ConcurrentMap methods - fun getIfPresent(key: K): V? { - val hash = hash(key) - return segmentFor(hash).get(key, hash) - } - - fun put( - key: K, - value: V, - ): V? { - val hash = hash(key) - return segmentFor(hash).put(key, hash, value, false) - } - - fun getOrPut( - key: K, - defaultValue: () -> V, - ): V { - val hash = hash(key) - return segmentFor(hash).getOrPut(key, hash, defaultValue) - } - - fun clear() { - for (segment in segments) { - segment?.clear() - } - } - - fun remove(key: K): V? { - val hash = hash(key) - return segmentFor(hash).remove(key, hash) - } - - fun getAllPresent(): Map { - return buildMap { - for (segment in segments) { - segment?.let { putAll(it.activeEntries()) } - } - } - } - - // Serialization Support - internal class LocalManualCache private constructor(private val localCache: LocalCache) : - Cache { - constructor(builder: CacheBuilder) : this(LocalCache(builder)) - - // Cache methods - override fun getIfPresent(key: K): V? { - return localCache.getIfPresent(key) - } - - override fun put( - key: K, - value: V, - ) { - localCache.put(key, value) - } - - override fun invalidate(key: K) { - localCache.remove(key) - } - - override fun getOrPut( - key: K, - valueProducer: () -> V, - ): V { - return localCache.getOrPut(key, valueProducer) - } - - override fun getAllPresent(keys: List<*>): Map { - return localCache.getAllPresent().filterKeys { it in keys } - } - - override fun getAllPresent(): Map { - return localCache.getAllPresent() - } - - override fun invalidateAll(keys: List) { - TODO("Not yet implemented") - } - - override fun putAll(map: Map) { - TODO("Not yet implemented") - } - - override fun invalidateAll() { - localCache.clear() - } - - override fun size(): Long { - TODO("Not yet implemented") - } - } - - companion object { - /* - * The basic strategy is to subdivide the table among Segments, each of which itself is a - * concurrently readable hash table. The map supports non-blocking reads and concurrent writes - * across different segments. - * - * If a maximum size is specified, a best-effort bounding is performed per segment, using a - * page-replacement algorithm to determine which entries to evict when the capacity has been - * exceeded. - * - * The page replacement algorithm's data structures are kept casually consistent with the map. The - * ordering of writes to a segment is sequentially consistent. An update to the map and recording - * of reads may not be immediately reflected on the algorithm's data structures. These structures - * are guarded by a lock and operations are applied in batches to avoid lock contention. The - * penalty of applying the batches is spread across threads so that the amortized cost is slightly - * higher than performing just the operation without enforcing the capacity constraint. - * - * This implementation uses a per-segment queue to record a memento of the additions, removals, - * and accesses that were performed on the map. The queue is drained on writes and when it exceeds - * its capacity threshold. - * - * The Least Recently Used page replacement algorithm was chosen due to its simplicity, high hit - * rate, and ability to be implemented with O(1) time complexity. The initial LRU implementation - * operates per-segment rather than globally for increased implementation simplicity. We expect - * the cache hit rate to be similar to that of a global LRU algorithm. - */ - // Constants - private val OneWeigher: Weigher = { _, _ -> 1 } - - /** - * The maximum capacity, used if a higher value is implicitly specified by either of the - * constructors with arguments. MUST be a power of two <= 1<<30 to ensure that entries are - * indexable using ints. - */ - const val MAXIMUM_CAPACITY = 1 shl 30 - - /** - * The maximum number of segments to allow; used to bound constructor arguments. - */ - const val MAX_SEGMENTS = 1 shl 16 // slightly conservative - - /** - * Number of cache access operations that can be buffered per segment before the cache's recency - * ordering information is updated. This is used to avoid lock contention by recording a memento - * of reads and delaying a lock acquisition until the threshold is crossed or a mutation occurs. - * - * - * - * This must be a (2^n)-1 as it is used as a mask. - */ - const val DRAIN_THRESHOLD = 0x3F - - /** - * Placeholder. Indicates that the value hasn't been set yet. - */ - private val UNSET: ValueReference = - object : ValueReference { - override fun get(): Any? { - return null - } - - override val weight: Int - get() = 0 - override val entry: ReferenceEntry? - get() = null - - override fun copyFor( - value: Any?, - entry: ReferenceEntry?, - ): ValueReference { - return this - } - - override val isActive: Boolean - get() = false - - override fun notifyNewValue(newValue: Any) {} - } - - /** - * Singleton placeholder that indicates a value is being loaded. - */ - @Suppress("UNCHECKED_CAST") - private fun unset() = UNSET as ValueReference - - @Suppress("UNCHECKED_CAST") - private fun nullEntry() = NullEntry as ReferenceEntry - - private val DISCARDING_QUEUE: MutableQueue = - object : MutableQueue { - override fun add(value: Any) {} - - override fun peek(): Any? = null - - override fun poll(): Any? = null - - override fun iterator(): MutableIterator = HashSet().iterator() - - override val size: Int = 0 - - override fun isEmpty(): Boolean = true - - override fun clear() {} - - override fun remove(element: Any): Boolean = false - - override fun contains(element: Any): Boolean = false - } - - /** - * Queue that discards all elements. - */ - @Suppress("UNCHECKED_CAST") - private fun discardingQueue(): MutableQueue = DISCARDING_QUEUE as MutableQueue - - /** - * Applies a supplemental hash function to a given hash code, which defends against poor quality - * hash functions. This is critical when the concurrent hash map uses power-of-two length hash - * tables, that otherwise encounter collisions for hash codes that do not differ in lower or - * upper bits. - * - * @param hash hash code - */ - fun rehash(hash: Int): Int { - // Spread bits to regularize both segment and index locations, - // using variant of single-word Wang/Jenkins hash. - // TODO(kevinb): use Hashing/move this to Hashing? - var h = hash - h += h shl 15 xor -0x3283 - h = h xor (h ushr 10) - h += h shl 3 - h = h xor (h ushr 6) - h += (h shl 2) + (h shl 14) - return h xor (h ushr 16) - } - - // queues - // Guarded By Segment.this - private fun connectAccessOrder( - previous: ReferenceEntry, - next: ReferenceEntry, - ) { - previous.nextInAccessQueue = next - next.previousInAccessQueue = previous - } - - // Guarded By Segment.this - private fun nullifyAccessOrder(nulled: ReferenceEntry) { - val nullEntry: ReferenceEntry = nullEntry() - nulled.nextInAccessQueue = nullEntry - nulled.previousInAccessQueue = nullEntry - } - - // Guarded By Segment.this - private fun connectWriteOrder( - previous: ReferenceEntry, - next: ReferenceEntry, - ) { - previous.nextInWriteQueue = next - next.previousInWriteQueue = previous - } - - // Guarded By Segment.this - private fun nullifyWriteOrder(nulled: ReferenceEntry) { - val nullEntry: ReferenceEntry = nullEntry() - nulled.nextInWriteQueue = nullEntry - nulled.previousInWriteQueue = nullEntry - } - } - - /** - * Creates a new, empty map with the specified strategy, initial capacity and concurrency level. - */ - init { - this.maxWeight = - when { - builder.expireAfterAccess == Duration.ZERO || builder.expireAfterWrite == Duration.ZERO -> 0L - builder.weigher != null -> builder.maximumWeight - else -> builder.maximumSize - } - this.weigher = builder.weigher ?: OneWeigher as Weigher - - this.expireAfterAccessNanos = - (if (builder.expireAfterAccess == Duration.INFINITE) Duration.ZERO else builder.expireAfterAccess) - .inWholeNanoseconds - - this.expireAfterWriteNanos = - (if (builder.expireAfterWrite == Duration.INFINITE) Duration.ZERO else builder.expireAfterWrite) - .inWholeNanoseconds - - this.ticker = if (recordsTime) (builder.ticker ?: MonotonicTicker) else ({ 0L }) - this.entryFactory = EntryFactory.getFactory(usesAccessEntries, usesWriteEntries) - var initialCapacity = builder.initialCapacity.coerceAtMost(MAXIMUM_CAPACITY) - if (evictsBySize && !customWeigher) { - initialCapacity = min(initialCapacity, maxWeight.toInt()) - } - val concurrencyLevel = builder.concurrencyLevel.coerceAtMost(MAX_SEGMENTS) - // Find the lowest power-of-two segmentCount that exceeds concurrencyLevel, unless - // maximumSize/Weight is specified in which case ensure that each segment gets at least 10 - // entries. The special casing for size-based eviction is only necessary because that eviction - // happens per segment instead of globally, so too many segments compared to the maximum size - // will result in random eviction behavior. - var segmentShift = 0 - var segmentCount = 1 - while (segmentCount < concurrencyLevel && (!evictsBySize || segmentCount * 20 <= maxWeight)) { - ++segmentShift - segmentCount = segmentCount shl 1 - } - this.segmentShift = 32 - segmentShift - segmentMask = segmentCount - 1 - segments = arrayOfNulls(segmentCount) - var segmentCapacity = initialCapacity / segmentCount - if (segmentCapacity * segmentCount < initialCapacity) { - ++segmentCapacity - } - var segmentSize = 1 - while (segmentSize < segmentCapacity) { - segmentSize = segmentSize shl 1 - } - if (evictsBySize) { - // Ensure sum of segment max weights = overall max weights - var maxSegmentWeight = maxWeight / segmentCount + 1 - val remainder = maxWeight % segmentCount - for (i in segments.indices) { - if (i.toLong() == remainder) { - maxSegmentWeight-- - } - segments[i] = createSegment(segmentSize, maxSegmentWeight) - } - } else { - for (i in segments.indices) { - segments[i] = createSegment(segmentSize, -1) - } - } - } -} diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/MonotonicTicker.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/MonotonicTicker.kt deleted file mode 100644 index 30e346448..000000000 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/MonotonicTicker.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.mobilenativefoundation.store.cache5 - -import kotlin.time.ExperimentalTime -import kotlin.time.TimeSource - -@OptIn(ExperimentalTime::class) -internal val MonotonicTicker: Ticker = TimeSource.Monotonic.markNow().let { timeMark -> { timeMark.elapsedNow().inWholeNanoseconds } } diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/RemovalCause.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/RemovalCause.kt deleted file mode 100644 index 2f9f209fa..000000000 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/RemovalCause.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.mobilenativefoundation.store.cache5 - -/** - * The reason why a cached entry was removed. - * @param wasEvicted True if entry removal was automatic due to eviction. That is, the cause of removal is neither [EXPLICIT] or [REPLACED]. - * @author Charles Fry - * @since 10.0 - */ -internal enum class RemovalCause(val wasEvicted: Boolean) { - EXPLICIT(false), - REPLACED(false), - COLLECTED(true), - EXPIRED(true), - SIZE(true), -} diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCache.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCache.kt deleted file mode 100644 index 880b73042..000000000 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCache.kt +++ /dev/null @@ -1,171 +0,0 @@ -@file:Suppress("UNCHECKED_CAST") - -package org.mobilenativefoundation.store.cache5 - -import org.mobilenativefoundation.store.core5.KeyProvider -import org.mobilenativefoundation.store.core5.StoreData -import org.mobilenativefoundation.store.core5.StoreKey - -/** - * A class that represents a caching system with collection decomposition. - * Manages data with utility functions to get, invalidate, and add items to the cache. - * Depends on [StoreMultiCacheAccessor] for internal data management. - * @see [Cache]. - */ -class StoreMultiCache, Single : StoreData.Single, Collection : StoreData.Collection, Output : StoreData>( - private val keyProvider: KeyProvider, - singlesCache: Cache, Single> = CacheBuilder, Single>().build(), - collectionsCache: Cache, Collection> = CacheBuilder, Collection>().build(), -) : Cache { - private val accessor = - StoreMultiCacheAccessor( - singlesCache = singlesCache, - collectionsCache = collectionsCache, - ) - - private fun Key.castSingle() = this as StoreKey.Single - - private fun Key.castCollection() = this as StoreKey.Collection - - private fun StoreKey.Collection.cast() = this as Key - - private fun StoreKey.Single.cast() = this as Key - - override fun getIfPresent(key: Key): Output? { - return when (key) { - is StoreKey.Single<*> -> accessor.getSingle(key.castSingle()) as? Output - is StoreKey.Collection<*> -> accessor.getCollection(key.castCollection()) as? Output - else -> { - throw UnsupportedOperationException(invalidKeyErrorMessage(key)) - } - } - } - - override fun getOrPut( - key: Key, - valueProducer: () -> Output, - ): Output { - return when (key) { - is StoreKey.Single<*> -> { - val single = accessor.getSingle(key.castSingle()) as? Output - if (single != null) { - single - } else { - val producedSingle = valueProducer() - put(key, producedSingle) - producedSingle - } - } - - is StoreKey.Collection<*> -> { - val collection = accessor.getCollection(key.castCollection()) as? Output - if (collection != null) { - collection - } else { - val producedCollection = valueProducer() - put(key, producedCollection) - producedCollection - } - } - - else -> { - throw UnsupportedOperationException(invalidKeyErrorMessage(key)) - } - } - } - - override fun getAllPresent(keys: List<*>): Map { - val map = mutableMapOf() - keys.filterIsInstance>().forEach { key -> - when (key) { - is StoreKey.Collection -> { - val collection = accessor.getCollection(key) - collection?.let { map[key.cast()] = it as Output } - } - - is StoreKey.Single -> { - val single = accessor.getSingle(key) - single?.let { map[key.cast()] = it as Output } - } - } - } - - return map - } - - override fun getAllPresent(): Map { - return accessor.getAllPresent().mapKeys { (key, _) -> - when (key) { - is StoreKey.Collection -> key.cast() - is StoreKey.Single -> key.cast() - else -> throw UnsupportedOperationException(invalidKeyErrorMessage(key)) - } - } as Map - } - - override fun invalidateAll(keys: List) { - keys.forEach { key -> invalidate(key) } - } - - override fun invalidate(key: Key) { - when (key) { - is StoreKey.Single<*> -> accessor.invalidateSingle(key.castSingle()) - is StoreKey.Collection<*> -> accessor.invalidateCollection(key.castCollection()) - } - } - - override fun putAll(map: Map) { - map.entries.forEach { (key, value) -> put(key, value) } - } - - override fun put( - key: Key, - value: Output, - ) { - when (key) { - is StoreKey.Single<*> -> { - val single = value as Single - accessor.putSingle(key.castSingle(), single) - - val collectionKey = keyProvider.fromSingle(key.castSingle(), single) - val existingCollection = accessor.getCollection(collectionKey) - if (existingCollection != null) { - val updatedItems = - existingCollection.items.toMutableList().map { - if (it.id == single.id) { - single - } else { - it - } - } - val updatedCollection = existingCollection.copyWith(items = updatedItems) as Collection - accessor.putCollection(collectionKey, updatedCollection) - } - } - - is StoreKey.Collection<*> -> { - val collection = value as Collection - accessor.putCollection(key.castCollection(), collection) - - collection.items.forEach { - val single = it as? Single - if (single != null) { - accessor.putSingle(keyProvider.fromCollection(key.castCollection(), single), single) - } - } - } - } - } - - override fun invalidateAll() { - accessor.invalidateAll() - } - - override fun size(): Long { - return accessor.size() - } - - companion object { - fun invalidKeyErrorMessage(key: Any) = "Expected StoreKey.Single or StoreKey.Collection, but received ${key::class}" - } -} diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCacheAccessor.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCacheAccessor.kt deleted file mode 100644 index d7a69e0b5..000000000 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCacheAccessor.kt +++ /dev/null @@ -1,182 +0,0 @@ -package org.mobilenativefoundation.store.cache5 - -import kotlinx.atomicfu.locks.SynchronizedObject -import kotlinx.atomicfu.locks.synchronized -import org.mobilenativefoundation.store.core5.StoreData -import org.mobilenativefoundation.store.core5.StoreKey - -/** - * Responsible for managing and accessing cached data. - * Provides functionality to retrieve, store, and invalidate single items and collections of items. - * All operations are thread-safe, ensuring safe usage across multiple threads. - * - * The thread safety of this class is ensured through the use of synchronized blocks. - * Synchronized blocks guarantee only one thread can execute any of the methods at a time. - * This prevents concurrent modifications and ensures consistency of the data. - * - * @param Id The type of the identifier used for the data. - * @param Collection The type of the data collection. - * @param Single The type of the single data item. - * @property singlesCache The cache used to store single data items. - * @property collectionsCache The cache used to store collections of data items. - */ -class StoreMultiCacheAccessor, Single : StoreData.Single>( - private val singlesCache: Cache, Single>, - private val collectionsCache: Cache, Collection>, -) : SynchronizedObject() { - private val keys = mutableSetOf>() - - /** - * Retrieves a collection of items from the cache using the provided key. - * - * This operation is thread-safe. - * - * @param key The key used to retrieve the collection. - * @return The cached collection or null if it's not present. - */ - fun getCollection(key: StoreKey.Collection): Collection? = - synchronized(this) { - collectionsCache.getIfPresent(key) - } - - /** - * Retrieves an individual item from the cache using the provided key. - * - * This operation is thread-safe. - * - * @param key The key used to retrieve the single item. - * @return The cached single item or null if it's not present. - */ - fun getSingle(key: StoreKey.Single): Single? = - synchronized(this) { - singlesCache.getIfPresent(key) - } - - /** - * Retrieves all items from the cache. - * - * This operation is thread-safe. - */ - fun getAllPresent(): Map, Any> = - synchronized(this) { - val result = mutableMapOf, Any>() - for (key in keys) { - when (key) { - is StoreKey.Single -> { - val single = singlesCache.getIfPresent(key) - if (single != null) { - result[key] = single - } - } - - is StoreKey.Collection -> { - val collection = collectionsCache.getIfPresent(key) - if (collection != null) { - result[key] = collection - } - } - } - } - result - } - - /** - * Stores a collection of items in the cache and updates the key set. - * - * This operation is thread-safe. - * - * @param key The key associated with the collection. - * @param collection The collection to be stored in the cache. - */ - fun putCollection( - key: StoreKey.Collection, - collection: Collection, - ) = synchronized(this) { - collectionsCache.put(key, collection) - keys.add(key) - } - - /** - * Stores an individual item in the cache and updates the key set. - * - * This operation is thread-safe. - * - * @param key The key associated with the single item. - * @param single The single item to be stored in the cache. - */ - fun putSingle( - key: StoreKey.Single, - single: Single, - ) = synchronized(this) { - singlesCache.put(key, single) - keys.add(key) - } - - /** - * Removes all cache entries and clears the key set. - * - * This operation is thread-safe. - */ - fun invalidateAll() = - synchronized(this) { - collectionsCache.invalidateAll() - singlesCache.invalidateAll() - keys.clear() - } - - /** - * Removes an individual item from the cache and updates the key set. - * - * This operation is thread-safe. - * - * @param key The key associated with the single item to be invalidated. - */ - fun invalidateSingle(key: StoreKey.Single) = - synchronized(this) { - singlesCache.invalidate(key) - keys.remove(key) - } - - /** - * Removes a collection of items from the cache and updates the key set. - * - * This operation is thread-safe. - * - * @param key The key associated with the collection to be invalidated. - */ - fun invalidateCollection(key: StoreKey.Collection) = - synchronized(this) { - collectionsCache.invalidate(key) - keys.remove(key) - } - - /** - * Calculates the total count of items in the cache, including both single items and items in collections. - * - * This operation is thread-safe. - * - * @return The total count of items in the cache. - */ - fun size(): Long = - synchronized(this) { - var count = 0L - for (key in keys) { - when (key) { - is StoreKey.Single -> { - val single = singlesCache.getIfPresent(key) - if (single != null) { - count++ - } - } - - is StoreKey.Collection -> { - val collection = collectionsCache.getIfPresent(key) - if (collection != null) { - count += collection.items.size - } - } - } - } - count - } -} diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Ticker.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Ticker.kt deleted file mode 100644 index 434cfc61f..000000000 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Ticker.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.mobilenativefoundation.store.cache5 - -/** - * @return Number of nanoseconds elapsed since the ticker's fixed point of reference. - */ -typealias Ticker = () -> Long diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Weigher.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Weigher.kt deleted file mode 100644 index dbb73292e..000000000 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Weigher.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.mobilenativefoundation.store.cache5 - -/** - * @return Weight of a cache entry. Must be non-negative. There is no unit for entry weights. Rather, they are simply relative to each other. - */ -typealias Weigher = (key: Key, value: Value) -> Int diff --git a/cache/src/commonTest/kotlin/org/mobilenativefoundation/store/cache5/CacheTests.kt b/cache/src/commonTest/kotlin/org/mobilenativefoundation/store/cache5/CacheTests.kt deleted file mode 100644 index af1a11de5..000000000 --- a/cache/src/commonTest/kotlin/org/mobilenativefoundation/store/cache5/CacheTests.kt +++ /dev/null @@ -1,108 +0,0 @@ -package org.mobilenativefoundation.store.cache5 - -import kotlinx.coroutines.test.runTest -import kotlin.test.Ignore -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.time.Duration.Companion.milliseconds - -class CacheTests { - private val cache: Cache = CacheBuilder().build() - - @Test - fun getIfPresent() { - cache.put("key", "value") - assertEquals("value", cache.getIfPresent("key")) - } - - @Test - fun getOrPut() { - assertEquals("value", cache.getOrPut("key") { "value" }) - } - - @Test - fun getAllPresent() { - cache.put("key1", "value1") - cache.put("key2", "value2") - assertEquals(mapOf("key1" to "value1", "key2" to "value2"), cache.getAllPresent(listOf("key1", "key2"))) - assertEquals(mapOf("key1" to "value1", "key2" to "value2"), cache.getAllPresent()) - } - - @Ignore // Not implemented yet - @Test - fun putAll() { - cache.putAll(mapOf("key1" to "value1", "key2" to "value2")) - assertEquals(mapOf("key1" to "value1", "key2" to "value2"), cache.getAllPresent(listOf("key1", "key2"))) - } - - @Test - fun invalidate() { - cache.put("key", "value") - cache.invalidate("key") - assertEquals(null, cache.getIfPresent("key")) - } - - @Ignore // Not implemented yet - @Test - fun invalidateAll() { - cache.put("key1", "value1") - cache.put("key2", "value2") - cache.invalidateAll(listOf("key1", "key2")) - assertEquals(null, cache.getIfPresent("key1")) - assertEquals(null, cache.getIfPresent("key2")) - } - - @Ignore // Not implemented yet - @Test - fun size() { - cache.put("key1", "value1") - cache.put("key2", "value2") - assertEquals(2, cache.size()) - } - - @Test - fun maximumSize() { - val cache = CacheBuilder().maximumSize(1).build() - cache.put("key1", "value1") - cache.put("key2", "value2") - assertEquals(null, cache.getIfPresent("key1")) - assertEquals("value2", cache.getIfPresent("key2")) - } - - @Test - fun maximumWeight() { - val cache = CacheBuilder().weigher(399) { _, _ -> 100 }.build() - cache.put("key1", "value1") - cache.put("key2", "value2") - assertEquals(null, cache.getIfPresent("key1")) - assertEquals("value2", cache.getIfPresent("key2")) - } - - @Test - fun expireAfterAccess() = - runTest { - var timeNs = 0L - val cache = CacheBuilder().expireAfterAccess(100.milliseconds).ticker { timeNs }.build() - cache.put("key", "value") - - timeNs += 50.milliseconds.inWholeNanoseconds - assertEquals("value", cache.getIfPresent("key")) - - timeNs += 100.milliseconds.inWholeNanoseconds - assertEquals(null, cache.getIfPresent("key")) - } - - @Test - fun expireAfterWrite() = - runTest { - var timeNs = 0L - val cache = CacheBuilder().expireAfterWrite(100.milliseconds).ticker { timeNs }.build() - cache.put("key", "value") - - timeNs += 50.milliseconds.inWholeNanoseconds - assertEquals("value", cache.getIfPresent("key")) - - timeNs += 50.milliseconds.inWholeNanoseconds - assertEquals(null, cache.getIfPresent("key")) - } -} diff --git a/core/api/jvm/core.api b/core/api/jvm/core.api deleted file mode 100644 index 7a452a0a2..000000000 --- a/core/api/jvm/core.api +++ /dev/null @@ -1,69 +0,0 @@ -public abstract interface annotation class org/mobilenativefoundation/store/core5/ExperimentalStoreApi : java/lang/annotation/Annotation { -} - -public final class org/mobilenativefoundation/store/core5/InsertionStrategy : java/lang/Enum { - public static final field APPEND Lorg/mobilenativefoundation/store/core5/InsertionStrategy; - public static final field PREPEND Lorg/mobilenativefoundation/store/core5/InsertionStrategy; - public static final field REPLACE Lorg/mobilenativefoundation/store/core5/InsertionStrategy; - public static fun getEntries ()Lkotlin/enums/EnumEntries; - public static fun valueOf (Ljava/lang/String;)Lorg/mobilenativefoundation/store/core5/InsertionStrategy; - public static fun values ()[Lorg/mobilenativefoundation/store/core5/InsertionStrategy; -} - -public abstract interface class org/mobilenativefoundation/store/core5/KeyProvider { - public abstract fun fromCollection (Lorg/mobilenativefoundation/store/core5/StoreKey$Collection;Lorg/mobilenativefoundation/store/core5/StoreData$Single;)Lorg/mobilenativefoundation/store/core5/StoreKey$Single; - public abstract fun fromSingle (Lorg/mobilenativefoundation/store/core5/StoreKey$Single;Lorg/mobilenativefoundation/store/core5/StoreData$Single;)Lorg/mobilenativefoundation/store/core5/StoreKey$Collection; -} - -public abstract interface class org/mobilenativefoundation/store/core5/StoreData { -} - -public abstract interface class org/mobilenativefoundation/store/core5/StoreData$Collection : org/mobilenativefoundation/store/core5/StoreData { - public abstract fun copyWith (Ljava/util/List;)Lorg/mobilenativefoundation/store/core5/StoreData$Collection; - public abstract fun getItems ()Ljava/util/List; - public abstract fun insertItems (Lorg/mobilenativefoundation/store/core5/InsertionStrategy;Ljava/util/List;)Lorg/mobilenativefoundation/store/core5/StoreData$Collection; -} - -public abstract interface class org/mobilenativefoundation/store/core5/StoreData$Single : org/mobilenativefoundation/store/core5/StoreData { - public abstract fun getId ()Ljava/lang/Object; -} - -public abstract interface class org/mobilenativefoundation/store/core5/StoreKey { -} - -public abstract interface class org/mobilenativefoundation/store/core5/StoreKey$Collection : org/mobilenativefoundation/store/core5/StoreKey { - public abstract fun getInsertionStrategy ()Lorg/mobilenativefoundation/store/core5/InsertionStrategy; -} - -public abstract interface class org/mobilenativefoundation/store/core5/StoreKey$Collection$Cursor : org/mobilenativefoundation/store/core5/StoreKey$Collection { - public abstract fun getCursor ()Ljava/lang/Object; - public abstract fun getFilters ()Ljava/util/List; - public abstract fun getSize ()I - public abstract fun getSort ()Lorg/mobilenativefoundation/store/core5/StoreKey$Sort; -} - -public abstract interface class org/mobilenativefoundation/store/core5/StoreKey$Collection$Page : org/mobilenativefoundation/store/core5/StoreKey$Collection { - public abstract fun getFilters ()Ljava/util/List; - public abstract fun getPage ()I - public abstract fun getSize ()I - public abstract fun getSort ()Lorg/mobilenativefoundation/store/core5/StoreKey$Sort; -} - -public abstract interface class org/mobilenativefoundation/store/core5/StoreKey$Filter { - public abstract fun invoke (Ljava/util/List;)Ljava/util/List; -} - -public abstract interface class org/mobilenativefoundation/store/core5/StoreKey$Single : org/mobilenativefoundation/store/core5/StoreKey { - public abstract fun getId ()Ljava/lang/Object; -} - -public final class org/mobilenativefoundation/store/core5/StoreKey$Sort : java/lang/Enum { - public static final field ALPHABETICAL Lorg/mobilenativefoundation/store/core5/StoreKey$Sort; - public static final field NEWEST Lorg/mobilenativefoundation/store/core5/StoreKey$Sort; - public static final field OLDEST Lorg/mobilenativefoundation/store/core5/StoreKey$Sort; - public static final field REVERSE_ALPHABETICAL Lorg/mobilenativefoundation/store/core5/StoreKey$Sort; - public static fun getEntries ()Lkotlin/enums/EnumEntries; - public static fun valueOf (Ljava/lang/String;)Lorg/mobilenativefoundation/store/core5/StoreKey$Sort; - public static fun values ()[Lorg/mobilenativefoundation/store/core5/StoreKey$Sort; -} - diff --git a/core/build.gradle.kts b/core/build.gradle.kts deleted file mode 100644 index 171a92669..000000000 --- a/core/build.gradle.kts +++ /dev/null @@ -1,14 +0,0 @@ -plugins { - id("org.mobilenativefoundation.store.multiplatform") -} - -kotlin { - - sourceSets { - commonMain { - dependencies { - implementation(libs.kotlin.stdlib) - } - } - } -} diff --git a/core/gradle.properties b/core/gradle.properties deleted file mode 100644 index 1fe16b330..000000000 --- a/core/gradle.properties +++ /dev/null @@ -1,3 +0,0 @@ -POM_NAME=org.mobilenativefoundation.store -POM_ARTIFACT_ID=core5 -POM_PACKAGING=jar \ No newline at end of file diff --git a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/ExperimentalStoreApi.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/ExperimentalStoreApi.kt deleted file mode 100644 index 2191f0934..000000000 --- a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/ExperimentalStoreApi.kt +++ /dev/null @@ -1,10 +0,0 @@ -package org.mobilenativefoundation.store.core5 - -/** - * Marks declarations that are still **experimental** in store API. - * Declarations marked with this annotation are unstable and subject to change. - */ -@MustBeDocumented -@Retention(value = AnnotationRetention.BINARY) -@RequiresOptIn(level = RequiresOptIn.Level.WARNING) -annotation class ExperimentalStoreApi diff --git a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/InsertionStrategy.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/InsertionStrategy.kt deleted file mode 100644 index 23206ad77..000000000 --- a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/InsertionStrategy.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.mobilenativefoundation.store.core5 - -@ExperimentalStoreApi -enum class InsertionStrategy { - APPEND, - PREPEND, - REPLACE, -} diff --git a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/KeyProvider.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/KeyProvider.kt deleted file mode 100644 index 3e320a9fe..000000000 --- a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/KeyProvider.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.mobilenativefoundation.store.core5 - -@ExperimentalStoreApi -interface KeyProvider> { - fun fromCollection( - key: StoreKey.Collection, - value: Single, - ): StoreKey.Single - - fun fromSingle( - key: StoreKey.Single, - value: Single, - ): StoreKey.Collection -} diff --git a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreData.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreData.kt deleted file mode 100644 index 2011c4f83..000000000 --- a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreData.kt +++ /dev/null @@ -1,36 +0,0 @@ -package org.mobilenativefoundation.store.core5 - -/** - * An interface that defines items that can be uniquely identified. - * Every item that implements the [StoreData] interface must have a means of identification. - * This is useful in scenarios when data can be represented as singles or collections. - */ -@ExperimentalStoreApi -interface StoreData { - /** - * Represents a single identifiable item. - */ - interface Single : StoreData { - val id: Id - } - - /** - * Represents a collection of identifiable items. - */ - interface Collection> : StoreData { - val items: List - - /** - * Returns a new collection with the updated items. - */ - fun copyWith(items: List): Collection - - /** - * Inserts items to the existing collection and returns the updated collection. - */ - fun insertItems( - strategy: InsertionStrategy, - items: List, - ): Collection - } -} diff --git a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreKey.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreKey.kt deleted file mode 100644 index ab367d564..000000000 --- a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreKey.kt +++ /dev/null @@ -1,61 +0,0 @@ -package org.mobilenativefoundation.store.core5 - -/** - * An interface that defines keys used by Store for data-fetching operations. - * Allows Store to fetch individual items and collections of items. - * Provides mechanisms for ID-based fetch, page-based fetch, and cursor-based fetch. - * Includes options for sorting and filtering. - */ -@ExperimentalStoreApi -interface StoreKey { - /** - * Represents a key for fetching an individual item. - */ - interface Single : StoreKey { - val id: Id - } - - /** - * Represents a key for fetching collections of items. - */ - interface Collection : StoreKey { - val insertionStrategy: InsertionStrategy - - /** - * Represents a key for page-based fetching. - */ - interface Page : Collection { - val page: Int - val size: Int - val sort: Sort? - val filters: List>? - } - - /** - * Represents a key for cursor-based fetching. - */ - interface Cursor : Collection { - val cursor: Id? - val size: Int - val sort: Sort? - val filters: List>? - } - } - - /** - * An enum defining sorting options that can be applied during fetching. - */ - enum class Sort { - NEWEST, - OLDEST, - ALPHABETICAL, - REVERSE_ALPHABETICAL, - } - - /** - * Defines filters that can be applied during fetching. - */ - interface Filter { - operator fun invoke(items: List): List - } -} diff --git a/docs/store6/important-defaults.md b/docs/store6/important-defaults.md new file mode 100644 index 000000000..e48a3b605 --- /dev/null +++ b/docs/store6/important-defaults.md @@ -0,0 +1,130 @@ +# Important defaults + +A zero-config `store { fetcher { … } }` (see the [Quickstart](quickstart.md)) already makes a lot +of decisions for you. This page names +every one of them, so you can find out here rather than in production. + +Each line ends in the conformance test that guarantees it. Those tests are the specification — if a +line here and its test ever disagree, the test is right and this page is a bug. + +> **Zero configuration and explicit expert configuration are byte-identical in behavior.** Setting +> the defaults by hand changes nothing observable: both sides produce the same trace and the same +> fetch count (`zeroConfig_and_expertConfig_observeIdenticalDefaults`). One honest limit on that +> guarantee: the equivalence is asserted over persistence, bookkeeper, freshness validator, and idle +> cap. It does not cover telemetry or overlay, which are unset on both sides. + +## Freshness + +See [Freshness policies](/docs/store6/concepts/freshness) for the complete per-call policy contract. + +- **The default is `Freshness.CachedOrFetch`.** An absent key fetches; a resident fresh value is + served without a second fetch (`defaultFreshness_isCachedOrFetch_zeroConfig`). +- **`MaxAge` within its bound serves the resident value without a second fetch** + (`maxAgeWithinBoundServesResidentWithoutSecondFetch`). +- **`MustBeFresh` always fetches**, even against a fresh resident value + (`mustBeFreshRefetchesFreshResident`). +- **`LocalOnly` never fetches.** It serves what is resident + (`localOnlyResidentIgnoresInvalidationForGetAndStream`), and when nothing is resident it fails + `Missing` without a `Loading` frame and without calling the fetcher + (`localOnlyWithoutResidentReportsMissingWithoutLoadingOrFetcherCall`). +- **Stale-while-revalidate is the shape of every refresh.** A stale value is served immediately and + the refresh produces **exactly one** terminal outcome — one fresh `Data`, or one served-stale + `Error`, or one `Revalidated`, never two + (`ac1a_staleWhileRevalidate_successEmitsStaleThenExactlyOneFreshData`, and its `ac1b`/`ac1c`/`ac1d` + siblings). A `get` on a stale resident value serves the stale value and refetches in the + background (`getOnStaleResident_servesStaleThenRefetchesInBackground`). + +## Retry + +- **The engine does not retry your fetcher. Zero retries, zero backoff, at zero configuration.** One + demand cycle invokes the fetcher exactly once, and a failure schedules no background retry — on + the terminalizing `get` path and on a live `stream` collector that survives the failure. A later + call is new demand rather than a continuation of the failed one + (`fetcherFailure_isNotRetried_zeroConfig`, which pins all three). If you want retries, they belong + in your fetcher, where you control the policy. +- **The source-of-truth reader subscription self-heals.** If the reader pipeline drops, the engine + re-subscribes on a fixed internal delay. This is engine behavior, not a knob: **the delay constant + is internal and not contractual**, and no test pins its literal value. + +## Cache and memory + +See [Memory and lifecycle](/docs/store6/concepts/memory-and-lifecycle) for eviction and +store-lifecycle guidance. + +- **In-memory source of truth and in-memory bookkeeper by default.** Nothing is written to disk + until you install persistence (`StoreBuilder.kt:180`, `StoreBuilder.kt:52`). +- **Idle residency is capped at 128 keys.** Quiescent engines park in an idle set bounded by that + cap, and the zero-config cap is the same as an explicit `maxIdleKeys(128)` + (`defaultMaxIdleKeys_matchesExplicit128Cap`, `quiescentKeys_parkInIdle_boundedByMaxIdleKeys`). +- **Eviction touches only quiescent engines.** A key with an active collector or an in-flight fetch + is never evicted, and residency stays bounded under churn + (`churn10kKeyCycles_neverEvictsHeldEngines_andResidencyStaysBounded`, + `activeCollector_pinsEngine_acrossChurn`). +- **Eviction is semantically invisible.** Destroying and recreating an engine preserves per-key + stale marks and namespace watermarks, and still drives the refetch you would have gotten + (`evictedEngine_recreation_semanticallyInvisible`). +- **Invalidation watermarks survive restart and eviction.** A namespace or global invalidation is + observed by a key a fresh store has never seen + (`invalidateNamespace_watermarkIsObservedForKeyUnseenByFreshStore`). +- **The memory cache never diverges from durable truth** (`memoryCache_neverDivergesFromDurableTruth`). + +## Deduplication and single-flighting + +- **N concurrent callers share one fetch.** 50 getters and 50 collectors demanding the same key + produce exactly one fetch, and all 100 observe its outcome + (`ac2_fiftyGettersAndFiftyCollectorsShareOneFetch`). +- **A stream arriving during an in-flight fetch piggybacks it** rather than starting a second one + (same test, plus `cancelledWaiterDoesNotCancelSharedFetch`). +- **Cancelling a waiter does not cancel the shared fetch.** The work commits and the next caller + reuses it (`cancelledWaiterDoesNotCancelSharedFetch`, + `getAfterStreamCommitted_servesResidentValueWithoutRefetch`). + +## Emission + +See [the read contract](/docs/store6/concepts/read-contract) for the complete result-kind and origin +semantics. + +- **Attribution is honest.** A network commit is `Origin.FETCHER` + (`preSubscribedCollectors_waitThroughQueuedAbsentThenDeliverWriterCurrentEcho`); an external + durable change is `Origin.SOT` (`externalWriteReturned_whileGraceHasOldMemory_convergesMemoryThenSot`); + an optimistic write is `Origin.OVERLAY` (`modifyingOverlay_stampsOverlayOrigin`); and a write-handle + `apply` echo is attributed `Origin.SOT` with no additional fetch + (`writeHandle_apply_emitsSotData_withoutFetch`). +- **A slow collector never blocks a fast one, or the engine** + (`slowCollector_doesNotBlockFastCollector_orEngine_andIsBoundedPerCycle`). +- **Conflation is per result kind, and lifecycle signals are never dropped.** A newer value of the + same kind supersedes an older queued one, but another kind never displaces a queued `Loading`, + `Error`, or `Revalidated` (`slowCollector_getsLatestDataAndEveryLifecycleSignalBeforeCompletion`). + Every collector eventually observes the latest row (`everyCollector_eventuallyObservesLatestRow`). +- **A `NotModified` response surfaces as exactly one `Revalidated(age)`** and clears staleness, not + as a redundant `Data` frame (`conditionalRefetch_notModified_emitsOwnerRevalidatedAndClearsStaleness`). +- **A post-clear stream starts absent or loading and never replays pre-clear data** + (`clear_thenNewStreamEmitsLoadingNeverStaleReplay`, + `clearNamespace_thenNewStreamEmitsLoadingNeverPreClearData`). A clear racing an in-flight fetch + cannot resurrect the discarded commit (`clearDuringInFlightFetch_commitDiscarded_noResurrection`). +- **Invalidation reaches live streams**, and survives a 10,000-invalidation burst without losing the + final staleness (`invalidate_activeStream_observesRefetchedData`, + `invalidate_burstOf10k_convergesWithoutLosingFinalStaleness`). + +## Reader grace + +A re-subscribe within a short window after the last collector leaves resumes the existing pipeline +rather than starting over with a fresh `Loading` frame. The behavior is exercised by the suite. The +window's millisecond value is an internal constant, and this page deliberately does not pin it. + +## What is *not* defaulted + +- **A fetcher is required**, and installing a source of truth does not substitute for one. + `store { }` without `fetcher { }`, `fetcherOfResult { }`, or `fetcher(Fetcher)` fails at + build time with a message that says so (`StoreBuilder.kt:176-179`). +- **Telemetry is unset**, and the engine takes a null fast path rather than paying for a no-op sink + (`StoreBuilder.kt:58`). +- **Overlay is unset.** With no overlay installed, nothing is projected onto reads + (`StoreBuilder.kt:61`). + +--- + +*Every test named above lives under +[`store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/`](../../store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/).* + +*Last verified: 2026-08-10 · `main` @ `a6a156e9`, pre-6.0.0-alpha01* diff --git a/docs/store6/invalidate-vs-clear.md b/docs/store6/invalidate-vs-clear.md new file mode 100644 index 000000000..9b35c8c69 --- /dev/null +++ b/docs/store6/invalidate-vs-clear.md @@ -0,0 +1,160 @@ +# Invalidate or clear + +Both make a value go away. They are not interchangeable, and picking the wrong one produces one of +two bugs: a spinner where the user expected content, or stale content where the user expected a +spinner. + +The one-line version: + +- **`invalidate` marks stale.** The value stays, keeps being served, and gets refreshed. +- **`clear` removes.** The value is gone, and the next read starts from nothing. + +## What invalidate does + +`invalidate(key)` marks the value stale without removing it. On return, active streams of that key +have been signaled and will observe refetched data, and the resident value keeps being served as +stale in the meantime. + +Three properties are worth knowing because they are what make it safe to call: + +- **The stale mark is durable.** It survives process restart until a later successful fetch or + revalidation clears it. +- **It is level-triggered monotone state**, so a signal issued during any race window is never lost. + You do not have to reason about whether a fetch was in flight when you called it. +- **A live collector observes the refetch**, not a gap. This holds under load: a burst of 10,000 + invalidations converges without losing the final staleness. + +What the user sees: the content stays on screen, and updates in place when the fetch lands. That is +the stale-while-revalidate shape, and it is what you want for pull-to-refresh, for a "data changed" +push, and for anything where showing the previous answer beats showing nothing. + + + +```kotlin +import kotlinx.coroutines.flow.Flow +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.StoreResult + +class User(val id: String, val name: String) + +class UserKey(val id: String) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("users") + + override fun canonicalId(): String = id +} + +// Pull-to-refresh: the current value stays on screen while the refresh runs. +suspend fun onPullToRefresh( + store: Store, + key: UserKey, +) { + store.invalidate(key) +} + +// The screen's collector needs no special case. It receives the stale value with +// isStale = true and refreshing = true, then the fresh one. +fun observeUser( + store: Store, + key: UserKey, +): Flow> = store.stream(key) +``` + +## What clear does + +`clear(key)` destructively removes the value. On return the resident value is gone: active streams +observe the absent-value transition (a `Loading` frame) and then refetched data. Removal includes +the configured source-of-truth row and its freshness bookkeeping. + +Two properties matter here: + +- **An in-flight fetch that started before the clear can no longer commit.** Its waiters observe + `StoreError.Missing`. A clear racing a fetch cannot resurrect the discarded value. +- **A post-clear stream never replays pre-clear data.** It starts absent or loading. This is a + guarantee, not a timing accident, and it is what makes clear safe for sign-out. + +What the user sees: the content disappears and a loading state appears. That is correct when the old +value is not just outdated but *wrong to show* — a different user's data, data the current session is +no longer entitled to, a record the server says no longer exists. + + + +```kotlin +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace + +class User(val id: String, val name: String) + +class UserKey(val id: String) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("users") + + override fun canonicalId(): String = id +} + +class Document(val id: String, val title: String) + +class DocumentKey( + val organizationId: String, + val documentId: String, +) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("documents:$organizationId") + + override fun canonicalId(): String = documentId +} + +// Sign-out: the previous session's data must not be shown again, ever. +suspend fun onSignOut(store: Store) { + store.clearAll() +} + +// A record the server reported as deleted: remove it rather than refreshing it. +suspend fun onRecordDeleted( + store: Store, + key: UserKey, +) { + store.clear(key) +} + +// One tenant's data is no longer valid, the rest is fine. +suspend fun onOrganizationRevoked( + store: Store, + organizationId: String, +) { + store.clearNamespace(StoreNamespace("documents:$organizationId")) +} +``` + +## Choosing + +| You want to say | Use | The user sees | +|---|---|---| +| "This might be out of date, go check" | `invalidate` | Content stays, updates in place | +| "This is no longer valid to show" | `clear` | Content disappears, then loads | +| "Everything for this tenant is suspect" | `invalidateNamespace` | Each affected screen refreshes in place | +| "Everything for this tenant is revoked" | `clearNamespace` | Each affected screen empties, then loads | +| "Sign out" | `clearAll` | Everything empties | + +The test that decides it: **would showing the old value for another few hundred milliseconds be +wrong, or merely imperfect?** Wrong means clear. Imperfect means invalidate. + +## The stale-while-revalidate consequence + +This is where the two diverge most visibly. + +After `invalidate`, the next read serves the stale resident value **immediately** and refreshes in +the background. The refresh produces exactly one terminal outcome: one fresh `Data`, or one +served-stale `Error` if the fetch fails, or one `Revalidated(age)` if the server says nothing +changed. Never two. If the fetch fails, the user still has the old content and an error, rather than +an empty screen. + +After `clear`, there is nothing to serve. The next read is a cold read: `Loading`, then whatever the +fetcher returns. If the fetch fails, the user has an empty screen and an error. + +That asymmetry is the whole decision. `invalidate` degrades gracefully when the network is bad. +`clear` does not, because it cannot — you told it the old value was not safe to show. + +--- + +*Last verified: 2026-07-26 · `main` @ `c4fbaf4`, pre-6.0.0-alpha01* diff --git a/docs/store6/key-design.md b/docs/store6/key-design.md new file mode 100644 index 000000000..b040005a8 --- /dev/null +++ b/docs/store6/key-design.md @@ -0,0 +1,153 @@ +# Keys and namespaces + +Key design is the one thing Store asks you to get right. Everything else has a sensible default. A +key does not, because only you know how your data is shaped. + +It is worth the attention because a `StoreKey` is doing two jobs at once, and they have different +consequences when you get them wrong. + +## The two jobs + + + +```kotlin +public interface StoreKey { + public val namespace: StoreNamespace + public fun canonicalId(): String +} +``` + +**`canonicalId()` is identity.** Two keys with the same namespace and the same canonical id are the +same key: they share one in-flight fetch, one resident value, one stale mark. Two keys with +different canonical ids share nothing. This is the lever that controls deduplication. Get it too +narrow and you fetch the same thing twice under two names. Get it too wide and two different things +collide on one cache entry. + +**`namespace` is the unit of bulk operations.** It is what `invalidateNamespace` and `clearNamespace` +act on, and the durable watermark it carries covers keys the store has never even seen. This is the +lever that controls how much you can invalidate in one call. + +Because identity is a `String`, the rule is simple: **the canonical id must be stable for the +lifetime of the key, and it must contain everything that makes the result different.** If two +requests would return different bytes, their canonical ids must differ. + +## The smallest correct key + + + +```kotlin +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace + +class UserKey(val id: String) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("users") + + override fun canonicalId(): String = id +} +``` + +One namespace per record type, the record's own identifier as the canonical id. Start here. Most +keys never need to be more than this. + +## When the id needs more than an identifier + +If the same record can come back differently depending on the request, the difference belongs in the +canonical id. A user record fetched with expanded relationships is not the same value as the same +user fetched without them, and it must not overwrite it. + + + +```kotlin +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace + +class UserKey( + val id: String, + val includeOrganization: Boolean = false, +) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("users") + + override fun canonicalId(): String = + if (includeOrganization) "$id+org" else id +} +``` + +Two things to avoid here. Do not put anything in the canonical id that changes between two requests +you *want* deduplicated, such as a timestamp, a request id, or a nonce. And do not put a secret in +it, because the canonical id is a cache key and it will be written to your source of truth. + +## Choosing namespaces + +Namespaces are cheap. Use one per record type as the default, and split further when you want a +smaller blast radius for bulk invalidation. + +The question to ask is: *what do I want to invalidate together?* A pull-to-refresh on a user's +profile screen should invalidate the user, not everything. A sign-out should clear everything. A +"this organization's data changed" push notification is exactly the case for a per-organization +namespace, because it lets one call invalidate the right subset instead of all of it. + + + +```kotlin +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace + +class Document(val id: String, val title: String) + +class DocumentKey( + val organizationId: String, + val documentId: String, +) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("documents:$organizationId") + + override fun canonicalId(): String = documentId +} + +suspend fun onOrganizationChanged( + store: Store, + organizationId: String, +) { + store.invalidateNamespace(StoreNamespace("documents:$organizationId")) +} +``` + +## The payoff + +Once keys are right, the namespace-level operations become the tool you reach for: + +- `invalidate(key)` and `invalidateNamespace(namespace)` mark values stale without removing them. + Active streams are signaled on return and observe refetched data, and the resident value keeps + serving in the meantime. +- `clear(key)`, `clearNamespace(namespace)`, and `clearAll()` destructively remove values. +- The namespace and global watermarks are **durable**, so they cover keys that are not currently + resident and survive process restart. Invalidating a namespace before a key has ever been fetched + still makes that key's first read honest. + +Which of invalidate and clear you want is its own decision, and it has its own guide: +[Invalidate or Clear](invalidate-vs-clear.md). + +## Namespace equality + +Store's internal key registry derives the namespace component of key identity from +`namespace.value`. The `Bookkeeper` contract likewise normalizes that component, and namespace +operations, by the same value. `StoreNamespace` does not override `equals`, so direct equality +between instances remains reference equality. Do not use that result to infer registry or +bookkeeping matches: independently constructed namespaces with the same `.value` address the same +namespace in both. + +## One store or many + +Namespaces partition the maintenance blast radius within one store: use them when records share a +typed Store boundary but need separate `invalidateNamespace` or `clearNamespace` scopes. + +Freshness is not a store-topology choice. Each `stream` or `get` call selects its own `Freshness` +policy, so callers using one store can make different read decisions. + +Use separate stores when domains need independent typed value, failure, and lifecycle boundaries. +This separation does not make operations across stores atomic; no cross-store transaction is part +of the `Store` contract. + +--- + +*Last verified: 2026-08-10 · `main` @ `a6a156e9`, pre-6.0.0-alpha01* diff --git a/docs/store6/quickstart.md b/docs/store6/quickstart.md new file mode 100644 index 000000000..207ec61d7 --- /dev/null +++ b/docs/store6/quickstart.md @@ -0,0 +1,193 @@ +# Quickstart + +> Store 6 is in development and **nothing is published yet**. This page is the shape of the API as +> it stands on `main`; the install coordinates land with 6.0.0-alpha01. + +Store needs two things from you: a **key** that identifies what you want, and a **fetcher** that +knows how to go get it. Everything else — sharing one in-flight request across concurrent callers, +serving what is already resident, tracking staleness, bounding memory — is what Store does with +those two things. + +Here is the whole idea in five lines. + + + +```kotlin +val users = store { + fetcher { key -> FakeApi.getUser(key.id) } +} + +users.stream(UserKey("1")).collect { result -> render(result) } +val user = users.get(UserKey("2")) +``` + +The `store { }` block is verbatim from a module this repository compiles and runs in CI. The last +two lines are shown in their simplest form so the shape is legible. The program below is the exact +one CI executes, and it is where the real `stream` and `get` call sites live. + +## The whole program + +**This exact program compiles and runs on every pull request.** It is the `store6-quickstart` +module, executed by the `./gradlew :store6-quickstart:run` step in +[`.github/workflows/store6.yml`](../../.github/workflows/store6.yml). If it broke, this page +would not be shipping. + +Supporting declarations — the key, the model, and a stand-in service: + + + +```kotlin +package org.mobilenativefoundation.store6.quickstart + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.runBlocking +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.store + +/** Identifies a user by the stable identifier used by the example service. */ +private class UserKey( + /** The user identifier passed to the example service. */ + val id: String, +) : StoreKey { + /** The namespace shared by user records in the example store. */ + override val namespace: StoreNamespace = StoreNamespace("users") + + /** Returns the service identifier used to distinguish this user from other users. */ + override fun canonicalId(): String = id +} + +/** A user record returned by the example service. */ +private class User( + /** The stable identifier assigned to this user. */ + val id: String, + + /** The display name returned by the example service. */ + val name: String, +) + +/** Provides deterministic user data for the executable example. */ +private object FakeApi { + /** Returns a user after simulating an asynchronous service call. */ + suspend fun getUser(id: String): User { + delay(100) + return User(id, "User $id") + } +} +``` + +A `StoreKey` gives Store two things: a `namespace`, which groups related records so you can +invalidate or clear them together, and a `canonicalId()`, which distinguishes one record from +another inside that namespace. Key design is the one skill Store asks you to learn, and it has its +own guide: [Keys and Namespaces](key-design.md). + +And `main`: + + + +```kotlin +public fun main(): Unit = + runBlocking { + val users = store { + fetcher { key -> FakeApi.getUser(key.id) } + } + + users.stream(UserKey("1")).take(2).collect { result -> + when (result) { + is StoreResult.Loading -> println("Loading…") + is StoreResult.Data -> println("Data(name=${result.value.name}, origin=${result.origin})") + is StoreResult.Revalidated -> println("Revalidated(age=${result.age})") + is StoreResult.Error -> println("Error(${result.error})") + } + } + println("get: ${users.get(UserKey("2")).name}") + users.close() + } +``` + +## Reading the output + +`stream` gives you a `StoreResult`, and there are exactly four kinds. Handle all four and there is +no fifth case waiting to surprise you: + +- **`Loading`** — demand has been registered and no value is available yet. +- **`Data`** — a value, carrying an `origin` that tells you where it came from (`FETCHER`, `SOT`, + `MEMORY`, `OVERLAY`) and whether it is stale or refreshing. The example prints the origin because + attribution honesty is a contract, not a debugging aid. +- **`Revalidated`** — the server said nothing changed. You get one of these with the resident value's + age, rather than a redundant `Data` frame. +- **`Error`** — the fetch failed. If a stale value was resident, you will have been served it first. + +One detail worth naming so it does not read as magic: **`take(2)` is what ends this program.** +`stream` is an unbounded flow that stays live for as long as you collect it. The example takes the +first two frames — `Loading`, then `Data` — and stops. In an app you collect for the lifetime of the +screen instead, and `close()` the store when you are done with it. + +Continue with [the read contract](/docs/store6/concepts/read-contract) for result and failure +semantics, then [freshness policies](/docs/store6/concepts/freshness) for choosing how each read +uses resident and fetched data. + +## Write path (experimental) + +> **Experimental.** `store6-mutations` is a separate artifact and every public symbol is +> `@ExperimentalStoreApi`. It ships **with** 6.0.0-alpha01 — nothing here is published yet. +> +> **The spelling below is the current API surface.** The module is still experimental — shapes +> can change in any release — but the snippet below matches the implementation. + +Optimistic writes go through a journal, so they survive being offline and survive process death. +You get a mutation store instead of a plain one, and it is a `Store` — everything above still works. + + + +```kotlin +@OptIn(ExperimentalStoreApi::class) // required: the whole module is experimental +val users = mutationStore( + registry = registry, + server = server, + // Restart-safe key recovery is compile-time required. For keys reconstructible from the + // identity pair, the resolver is one line: + keyResolver = MutationKeyResolver { identity -> UserKey(identity.canonicalId) }, + valueCodecVersion = 1, + valueCodec = userJsonCodec, +) { + fetcher { key -> api.load(key) } +} + +users.mutate(key, renameRef, Rename("new name")) // journalled — the only write path +users.drain(key) // push pending intents and adopt each ack +``` + +The flow, end to end: + +1. **Offline enqueue.** `mutate` appends one intent and returns a mutation id. Nothing is pushed. +2. **Optimistic visibility.** `stream(key)` emits `Data(value = optimistic, origin = OVERLAY)`. +3. **Reconnect and acknowledge.** `drain(key)` pushes the pending intents and adopts each ack. +4. **Confirmed.** By the acknowledgement contract, the server's echo becomes the committed value, + attributed `SOT` or `MEMORY`, and the optimistic frame is retired rather than replayed. A stream + opened after the acknowledgement sees the echo. Convergence for a collector that was *already* + active across the acknowledgement is the subject of open engine work and is not yet a behavior + this page will promise. No redundant fetch happens anywhere in this sequence. + +Two properties that are design decisions rather than accidents: + +- **`runtime()` returns `null` on a mutation store, by design.** That withholds the raw write handle, + which is the library-granted way to write around the journal. Every consumer write stays + journalled, and there is no second path that could commit a value the journal never saw. +- **A pending write is `origin == OVERLAY`, not `isStale`.** `isStale` is never set on an overlay + frame, because an optimistic value genuinely is new. Drive a "saving…" indicator off the origin and + narrate the `OVERLAY` → `SOT` flip. See [the stability policy](../../STABILITY.md#9-reading-pending-writes-and-staleness) + for the full consumer guidance, and + note that `get` is unprojected: overlays apply only to `stream`. + +The alpha ships a two-step durable acknowledgement path, which means a crash in the acknowledgement +window leaves a replayable pending intent rather than losing your write, at the cost of the same +push possibly being re-sent. That tradeoff is stated in full in +[the stability policy](../../STABILITY.md#mutations). + +--- + +*Last verified: 2026-08-10 · `main` @ `a6a156e9`, pre-6.0.0-alpha01* diff --git a/docs/superpowers/plans/2026-08-06-store6-alpha01-source-doc-cleanup.md b/docs/superpowers/plans/2026-08-06-store6-alpha01-source-doc-cleanup.md new file mode 100644 index 000000000..2c2c39018 --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-store6-alpha01-source-doc-cleanup.md @@ -0,0 +1,376 @@ +# Store6 alpha01 Source Documentation Cleanup Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Every task is also governed by the `authoring:documentation-discipline` and `authoring:code-documentation` skills; read both before executing any task. + +**Goal:** Remove all internal organizational context (issue IDs, decision tags, approval state, personal names, speculative future-work claims) from KDoc and comments in the `store6-*` source tree, and bring the public-surface KDoc of the alpha01 artifacts up to interface-documentation standard, without changing a single executable token. + +**Architecture:** Grep-driven detection sweeps define a red/green cycle per module. Each rewrite preserves behavioral contracts verbatim in meaning while deleting provenance, then proves the documentation-only boundary with `apiCheck` (klib ABI dumps unchanged), `compileKotlinJvm`, `ktlintCheck`, and a manual comment-only hunk review. A final audit pass reviews the public API surface of the alpha01 artifacts against the interface-documentation checklist. + +**Tech Stack:** Kotlin Multiplatform, KDoc/Dokka (v1, `dokkaHtml` per module), Binary Compatibility Validator (klib mode, `api/` dumps), ktlint, spotless. `explicitApi()` is on for all `store6-*` modules via the `org.mobilenativefoundation.store.store6.multiplatform` convention plugin. + +## Global Constraints + +Every task's requirements implicitly include this entire section. + +### Publication boundary and scope + +- In-scope source: `store6-*/src/**/*.kt` (all source sets, including test sources). All of it ships in the public repository and the alpha01 artifacts. +- alpha01 artifact set (from STABILITY.md §"Artifact" table): `store6-core`, `store6-testing`, `store6-sqldelight`, `store6-room`, `store6-compose`, `store6-mutations`, `store6-bom` (no API surface); `store6-devtools` / `store6-devtools-inspector` target alpha02. `store6-mutations-sqldelight` and `store6-mutations-testing` have `api/` dumps and are published. +- The disclosure sweep (Tasks 2–6) covers **all** `store6-*` modules including unpublished ones (`store6-benchmarks`, `store6-quickstart`, `store6-extension-probe`, demo modules). The quality audit (Task 7) covers only the alpha01 artifact set. +- **Never edit anything under any `build/` directory.** `store6-sqldelight/build/borrowedConformance/**` is generated by borrowing `store6-core` test sources; cleaning the core test sources cleans it on the next build. +- Out of scope: README/ROADMAP/STABILITY/docs-site prose (revised separately, on `main` since PR #21), executable code, `api/*.api` dumps (generated — never hand-edit), build scripts. + +### Documentation-discipline digest (binding) + +- Master test: keep an element only when the reader needs it to use, change, operate, or reason about the system correctly. +- **Protected content — a doc pass must not alter:** identifiers, signatures, types, annotations (`@ExperimentalStoreApi`, `@DelicateStoreApi`, `@SubclassOptInRequired(...)`, `@JvmName`, suppressions), expect/actual declarations, source-set placement, imports, code blocks, inline code spans, KDoc `@param`/`@return` names, numbers, units, error names, and **behavioral guarantees**. Comment syntax does not make behavior-bearing content editable. +- **Behavioral guarantees survive verbatim in meaning.** Many flagged comments entangle a real contract with provenance (e.g. a tested invariant introduced by an internal issue). Delete the provenance; keep the contract. If you cannot verify a contract claim against code or tests, keep its meaning unchanged and record it in the inventory as `Unverifiable` — do not delete or "improve" it. +- **Banned in code documentation (universal anti-pattern, not optional):** private tracker IDs and URLs (Linear, `STORE-n`), internal issue numbers (`Issue 021`, `issue 007`), decision/ruling tags (`TD-11`, `RISK-2`, `FS-8`, `RD-1`, `D9`, `D15a`), PR numbers, internal owners and personal names ("Matt signs off"), approval/landing state ("PROVISIONAL pending…", "after issue 007 lands"), rollout status, and team shorthand. +- **Speculative intent is banned.** A sentence describing behavior that does not exist in the current tree ("Issue 024 selects its transactional decorator") is deleted, not rewritten. Document what the extension point does *today*; verify against code before keeping any claim. +- Also remove where encountered in flagged files: obvious narration (restating the adjacent code), hype, invented precision. Do not expand scope to unflagged files for style-only edits (that is Task 7's audit, which is bounded separately). +- Preserve complete existing documentation otherwise. Revise only the evidenced deficiency. +- Prose style: exact technical terms, short direct sentences, periods over semicolons. Repository conventions win over these defaults. + +### Rewrite rubric + +Classify every sweep hit into exactly one class and apply its rule: + +| Class | Signature | Rule | +| --- | --- | --- | +| **P1 tag** | A bare provenance token appended to an otherwise self-contained sentence: `(TD-11)`, `(D15a)`, `(D14/D15a)` | Delete the token (and its parentheses/connector). Keep the sentence unchanged. | +| **P2 governance stamp** | Approval/freeze state with internal referents: `Freeze candidate: … after issue 007 lands and Matt signs off …`, `PROVISIONAL pending Issue 021:` | Delete the stamp sentence or label. The `@ExperimentalStoreApi` annotation plus STABILITY.md already carry the public stability contract; do not restate it per declaration. | +| **P3 speculative intent** | Future-work claims: `Issue 024 selects…`, `so Issue 022 can normalize…`, `lands at Issue 021 (D2)` | Delete the future claim. If the surrounding sentence documents a current, code-verified contract, keep that part self-contained. | +| **P4 provenance-as-rationale** | Rationale expressed only via an internal referent: `parking is produced by Issue 023 over Issue 022's durable rows` | Rewrite the rationale in terms of the components themselves (e.g. name the drain pipeline / journal storage by their class or concept names), verified against code. If the rationale cannot be restated from evidence, delete the rationale and keep the contract. | +| **FP false positive** | e.g. "ruled out" in ordinary prose | Record in inventory as FP. No change. | + +### Worked examples (verbatim from the tree, 2026-08-06) + +**P2 — the freeze-candidate stamp** ([StoreWriteHandle.kt:12](store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreWriteHandle.kt:12) and ~15 sibling occurrences): + +``` +Before: + * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may + * still change until then. + +After: +(line deleted — the declaration's @ExperimentalStoreApi annotation and STABILITY.md's +seam-package freeze-candidate entry already state the public contract) +``` + +**P2 + P1 — entangled facade KDoc** ([MutationStore.kt:40](store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:40)): + +``` +Before: + * PROVISIONAL pending Issue 021: this facade deliberately withholds the raw engine write handle. + * Calling `runtime()` on it returns `null`; [keyEvents] is re-published so advisory access survives + * that narrowing, and it gains no `Rekeyed` variant. + +After: + * This facade deliberately withholds the raw engine write handle. Calling `runtime()` on it + * returns `null`; [keyEvents] is re-published so advisory access survives that narrowing, and it + * gains no `Rekeyed` variant. +``` + +Every contract clause survives; only the governance label is gone. Same file, line 38: `…through the canonical alias table (D15a).` → delete ` (D15a)` (P1). + +**P1 — bare tag** ([TransactionalSourceOfTruth.kt:8](store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/TransactionalSourceOfTruth.kt:8)): + +``` +Before: + * Optional atomicity capability for a [SourceOfTruth] (TD-11). Detectable via + +After: + * Optional atomicity capability for a [SourceOfTruth]. Detectable via +``` + +**P3 — speculative intent** ([MutationStoreBuilder.kt:34](store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt:34)): + +``` +Before: + * silently substituted (D9). Issue 024 selects its transactional decorator — or reports its + * explicit non-transactional fallback. + +After: + * silently substituted. +``` + +The future-work sentence is deleted entirely. Before keeping any remaining clause, read the surrounding code to confirm it describes current behavior. + +### Detection sweep (the "test") + +The sweep regex, used by every task (`BANNED` below). Run from the repo root: + +```bash +grep -rEn 'TD-[0-9]+|RISK-[0-9]+|STORE-[0-9]+|FS-[0-9]+|RD-[0-9]+|\(D[0-9]+[a-z]?\)|[Ii]ssue [0-9]{2,3}|PROVISIONAL|pending [Ii]ssue|PR #[0-9]+|linear\.app|\bMatt\b|signs? off|sign-off' \ + --include='*.kt' --exclude-dir=build store6-*/src +``` + +A second, classify-only pattern (higher false-positive rate — every hit gets classified in the inventory, FPs are allowed to remain): `ruling|ruled|adopted shape|erratum`. + +Baseline measured 2026-08-06: 65 line hits for the ID classes, 132 for the governance/speculative classes, 32 for personal-name/sign-off, across ~33 non-generated files (24 commonMain, 6 commonTest, 1 hostTest; concentration: store6-core 8 files, store6-mutations 8, store6-room 7, store6-compose 3, store6-testing 3, store6-sqldelight 2, store6-devtools 1, store6-mutations-testing 1). + +### Verification recipe (every rewrite task) + +After editing a module `:M`, all of the following, in order: + +1. `grep` sweep scoped to `M/src` → **0 hits** (recorded FPs excepted). +2. `./gradlew :M:compileKotlinJvm :M:apiCheck :M:ktlintCheck` → `BUILD SUCCESSFUL`. +3. `git diff --stat -- M/api` → empty (KDoc never reaches the klib ABI dump; any diff here means an executable token changed — **stop, revert the hunk, report**). +4. `git diff -- M/src` hunk review: every hunk touches only `/** … */` or `//` content. Any hunk touching code outside comment trivia → stop the affected edit, revert it, record the unresolved boundary in the inventory. +5. KDoc `[links]` you touched still name real declarations in the same source set (spot-check by reading; full Dokka proof runs once in Task 8). + +Proof-strength ceiling: this repo has no token/AST trivia-equivalence prover, so the strongest claim any task may report is **"repository checks plus hunk review"** — never "mechanically proven". + +### Repo conventions + +- Branch for the whole plan: `docs/alpha01-source-doc-cleanup` (created in Task 1). +- Commit style: `docs(): ` (matches existing `feat(mutations):` / `test(core):` history). +- Inventory file: `docs/superpowers/plans/2026-08-06-store6-doc-cleanup-inventory.md`, committed and updated by every task. Format: one table per module — columns `file:line | excerpt | class (P1–P4/FP/Unverifiable) | action taken`. + +--- + +### Task 1: Branch and baseline inventory + +**Files:** +- Create: `docs/superpowers/plans/2026-08-06-store6-doc-cleanup-inventory.md` + +**Interfaces:** +- Produces: the working branch and the committed baseline inventory that Tasks 2–8 update. Later tasks assume the branch exists and append to the inventory's per-module tables. + +- [ ] **Step 1: Create the branch** + +```bash +git checkout -b docs/alpha01-source-doc-cleanup +``` + +Expected: `Switched to a new branch 'docs/alpha01-source-doc-cleanup'`. + +- [ ] **Step 2: Capture the red baseline** + +Run the Detection sweep command from Global Constraints, plus the classify-only pattern, and save raw output: + +```bash +grep -rEn 'TD-[0-9]+|RISK-[0-9]+|STORE-[0-9]+|FS-[0-9]+|RD-[0-9]+|\(D[0-9]+[a-z]?\)|[Ii]ssue [0-9]{2,3}|PROVISIONAL|pending [Ii]ssue|PR #[0-9]+|linear\.app|\bMatt\b|signs? off|sign-off' \ + --include='*.kt' --exclude-dir=build store6-*/src | sort > /tmp/doc-sweep-baseline.txt +wc -l /tmp/doc-sweep-baseline.txt +``` + +Expected: on the order of 130–200 lines (baseline of 2026-08-06 measured 65+132+32 hits with overlap between patterns; re-measure, do not assume). + +- [ ] **Step 3: Write the inventory skeleton** + +Create `docs/superpowers/plans/2026-08-06-store6-doc-cleanup-inventory.md` with: the sweep command verbatim, the measured baseline count, and one empty table per module (columns `file:line | class | action`), one section per Task 2–7. Paste the per-module hit lists from the baseline file into the matching sections, unclassified. + +- [ ] **Step 4: Commit** + +```bash +git add docs/superpowers/plans/2026-08-06-store6-doc-cleanup-inventory.md +git commit -m "docs: add source-doc cleanup inventory baseline" +``` + +--- + +### Task 2: Delete the freeze-candidate stamp (cross-module, one pattern) + +**Files:** +- Modify: every file matched by `grep -rln 'signs off' --include='*.kt' --exclude-dir=build store6-*/src` — measured 2026-08-06: `store6-core/src/commonMain/.../seam/` (`StoreWriteHandle.kt`, `StoreRuntime.kt`, `Fetcher.kt`, `KeyEvents.kt`, `StoreTelemetry.kt`, `TransactionalSourceOfTruth.kt`, `FreshnessValidator.kt` ×6, `Bookkeeper.kt` ×2), `store6-testing/.../FakeStore.kt` ×4 and two commonTest files, `store6-room/.../RoomSourceOfTruth.kt` ×2, `store6-sqldelight/.../SqlDelightSourceOfTruth.kt`, `SqlDelightBookkeeper.kt`. Re-enumerate at execution time; the stamp text is identical everywhere. +- Modify: `docs/superpowers/plans/2026-08-06-store6-doc-cleanup-inventory.md` + +**Interfaces:** +- Consumes: branch + inventory from Task 1. +- Produces: zero `signs off|sign-off|\bMatt\b|issue 007` hits repo-wide; inventory rows classed P2/action=deleted. Task 3 assumes seam files no longer contain the stamp. + +- [ ] **Step 1: Enumerate (red)** + +```bash +grep -rn 'signs off' --include='*.kt' --exclude-dir=build store6-*/src | wc -l +``` + +Expected: >0 (baseline ~15 occurrences across ~14 files). + +- [ ] **Step 2: Delete every stamp instance** + +In each file, delete the full sentence `Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then.` including its `*`-prefixed continuation line where wrapped, leaving the rest of the KDoc block intact. If deleting it leaves an empty KDoc paragraph, remove the orphaned blank ` *` line too. Do not add replacement text (rubric P2: the `@ExperimentalStoreApi` annotation and STABILITY.md carry the contract). + +- [ ] **Step 3: Sweep clean (green)** + +```bash +grep -rEn '\bMatt\b|signs? off|sign-off|issue 007' --include='*.kt' --exclude-dir=build store6-*/src +``` + +Expected: no output, exit code 1. + +- [ ] **Step 4: Verify each touched module** + +```bash +./gradlew :store6-core:compileKotlinJvm :store6-core:apiCheck :store6-core:ktlintCheck \ + :store6-testing:compileKotlinJvm :store6-testing:apiCheck :store6-testing:ktlintCheck \ + :store6-room:compileKotlinJvm :store6-room:apiCheck :store6-room:ktlintCheck \ + :store6-sqldelight:compileKotlinJvm :store6-sqldelight:apiCheck :store6-sqldelight:ktlintCheck +git diff --stat -- store6-core/api store6-testing/api store6-room/api store6-sqldelight/api +``` + +Expected: `BUILD SUCCESSFUL`; empty diff stat. Then review `git diff` per the Verification recipe (comment-only hunks). + +- [ ] **Step 5: Update inventory and commit** + +```bash +git add -A store6-core store6-testing store6-room store6-sqldelight docs/superpowers/plans/2026-08-06-store6-doc-cleanup-inventory.md +git commit -m "docs: remove internal sign-off stamp from freeze-candidate KDoc" +``` + +--- + +### Task 3: store6-core remaining internal references + +**Files:** +- Modify: the store6-core files remaining in the inventory after Task 2 (baseline: 8 flagged files; `TransactionalSourceOfTruth.kt` carries `(TD-11)` at line 8 and possibly further tags; enumerate with the sweep scoped to `store6-core/src`). +- Modify: `docs/superpowers/plans/2026-08-06-store6-doc-cleanup-inventory.md` + +**Interfaces:** +- Consumes: stamp-free seam files from Task 2. +- Produces: sweep-clean `store6-core/src`; inventory rows classified. Task 6 relies on cleaned commonTest sources here to fix `store6-sqldelight/build/borrowedConformance` at its generation source. + +- [ ] **Step 1: Enumerate (red)** — run the full Detection sweep scoped to `store6-core/src`; paste hits into the inventory section. +- [ ] **Step 2: Classify and rewrite** — apply the rubric per hit. P1 examples: `(TD-11)`. For each P3/P4 hit, read the surrounding declaration and its tests before keeping any claim; mark kept-but-unproven claims `Unverifiable` in the inventory rather than deleting contract meaning. This includes commonTest and hostTest sources. +- [ ] **Step 3: Sweep clean (green)** — full Detection sweep scoped to `store6-core/src`; expected: no output (recorded FPs excepted). +- [ ] **Step 4: Verify** — `./gradlew :store6-core:compileKotlinJvm :store6-core:apiCheck :store6-core:ktlintCheck`; `git diff --stat -- store6-core/api` empty; comment-only hunk review. Also run the module's own tests if any touched comment sat inside a test file: `./gradlew :store6-core:jvmTest` → `BUILD SUCCESSFUL`. +- [ ] **Step 5: Update inventory and commit** — `git commit -m "docs(core): remove internal issue and design-doc references from KDoc"`. + +--- + +### Task 4: store6-mutations + +**Files:** +- Modify (baseline 8 flagged files, re-enumerate): `MutationStore.kt` (facade KDoc lines 36–53 incl. the worked example, plus lines ~294, ~310, ~366), `MutationStoreBuilder.kt` (~34, ~95, ~151), `MutationProtocol.kt` (~368), `MutationEvents.kt` (~293, `TD-8 note:`), `MutatorRegistry.kt` (~17), `MutationSourceOfTruth.kt` (~24), plus any others the sweep lists. +- Modify: `docs/superpowers/plans/2026-08-06-store6-doc-cleanup-inventory.md` + +**Interfaces:** +- Consumes: rubric + worked examples (two of the four are from this module). +- Produces: sweep-clean `store6-mutations/src`; inventory rows classified. + +- [ ] **Step 1: Enumerate (red)** — Detection sweep scoped to `store6-mutations/src` (baseline: densest module; expect ~30+ hits). +- [ ] **Step 2: Classify and rewrite** — apply the rubric. This module is where P3/P4 dominate ("Issue 022 can normalize the throw", "parking is produced by Issue 023 over Issue 022's durable rows", "Issue 024's transactional decorator can select…"). For every P4, restate the rationale using the component's real names in this tree (journal storage, drain pipeline, alias table) only after confirming the claim in code; otherwise delete the rationale, keep the contract. **Extra caution:** several comments here encode tested invariants (`runtime()` returning `null` on the facade; the deliberate adopt-then-retire ack ordering on the non-transactional path). Their meaning must survive; find the pinning test before touching the sentence, and record the test's FQN in the inventory `action` column. +- [ ] **Step 3: Sweep clean (green)** — scoped sweep; expected: no output. +- [ ] **Step 4: Verify** — `./gradlew :store6-mutations:compileKotlinJvm :store6-mutations:apiCheck :store6-mutations:ktlintCheck`; `git diff --stat -- store6-mutations/api` empty; comment-only hunk review; `./gradlew :store6-mutations:jvmTest` if test files were touched. +- [ ] **Step 5: Update inventory and commit** — `git commit -m "docs(mutations): make KDoc contracts self-contained, drop internal issue references"`. + +--- + +### Task 5: Adapter modules — store6-room, store6-sqldelight, store6-compose, store6-mutations-sqldelight + +**Files:** +- Modify: remaining flagged files in these four modules (baseline: room 7 files, sqldelight 2, compose 3, mutations-sqldelight per sweep; Task 2 already removed the stamp lines). +- Modify: `docs/superpowers/plans/2026-08-06-store6-doc-cleanup-inventory.md` + +**Interfaces:** +- Consumes: cleaned core seam KDoc (adapters cross-link seam types; links must keep resolving). +- Produces: sweep-clean sources for all four modules; inventory rows classified. + +- [ ] **Step 1: Enumerate (red)** — Detection sweep scoped to each of the four `*/src` trees. +- [ ] **Step 2: Classify and rewrite** — rubric as above. Adapters mostly carry P1 tags and P4 rationale referencing core issues; the same verify-before-keep rule applies. +- [ ] **Step 3: Sweep clean (green)** — scoped sweeps; expected: no output. +- [ ] **Step 4: Verify** — `./gradlew :store6-room:compileKotlinJvm :store6-room:apiCheck :store6-room:ktlintCheck :store6-sqldelight:compileKotlinJvm :store6-sqldelight:apiCheck :store6-sqldelight:ktlintCheck :store6-compose:compileKotlinJvm :store6-compose:apiCheck :store6-compose:ktlintCheck :store6-mutations-sqldelight:compileKotlinJvm :store6-mutations-sqldelight:apiCheck :store6-mutations-sqldelight:ktlintCheck`; empty `git diff --stat -- */api`; comment-only hunk review. (If a module lacks the `compileKotlinJvm` task, substitute that module's `compileReleaseKotlinAndroid` and note it in the inventory.) +- [ ] **Step 5: Update inventory and commit** — `git commit -m "docs(adapters): remove internal references from room, sqldelight, compose, mutations-sqldelight"`. + +--- + +### Task 6: Testing/devtools modules, test sources, unpublished modules + +**Files:** +- Modify: remaining flagged files in `store6-testing`, `store6-mutations-testing`, `store6-devtools`, `store6-devtools-inspector`, plus all remaining commonTest/hostTest hits repo-wide, plus any hits in `store6-benchmarks`, `store6-quickstart`, `store6-extension-probe`, `store6-compose-demo`, `store6-devtools-demo`. +- Modify: `docs/superpowers/plans/2026-08-06-store6-doc-cleanup-inventory.md` + +**Interfaces:** +- Consumes: all prior module cleanups. +- Produces: the full-tree Detection sweep returns zero (recorded FPs excepted) — the precondition for Task 8's gate. + +- [ ] **Step 1: Enumerate (red)** — full Detection sweep (all `store6-*/src`); everything still listed belongs to this task. +- [ ] **Step 2: Classify and rewrite** — rubric as above. Test-source comments follow the same rules; a test named after or commenting an internal ruling keeps its behavior description and loses the ruling reference. **Do not rename test functions or classes** — identifiers are protected; only comments/KDoc change here. +- [ ] **Step 3: Sweep clean (green)** — full Detection sweep; expected: no output, exit 1. +- [ ] **Step 4: Verify** — `./gradlew :store6-testing:compileKotlinJvm :store6-testing:apiCheck :store6-testing:ktlintCheck :store6-mutations-testing:compileKotlinJvm :store6-mutations-testing:apiCheck :store6-devtools:compileKotlinJvm :store6-devtools:apiCheck` plus `:store6-testing:jvmTest` and `:store6-mutations-testing:jvmTest` (test files were touched); empty `api/` diffs; comment-only hunk review. +- [ ] **Step 5: Update inventory and commit** — `git commit -m "docs: clear internal references from testing, devtools, and test sources"`. + +--- + +### Task 7: Public-surface KDoc quality audit — alpha01 artifacts (audit, then bounded fixes) + +**Files:** +- Modify: `docs/superpowers/plans/2026-08-06-store6-doc-cleanup-inventory.md` (audit findings section) +- Modify: only files named by recorded findings (fix step) + +**Interfaces:** +- Consumes: disclosure-clean tree from Tasks 2–6. +- Produces: an audit table (finding classes: Missing / Stale / Contradictory / Duplicated / Unverifiable / Misleading / Unnecessary / Sufficient) over the public declarations of `store6-core`, `store6-mutations`, `store6-testing`, `store6-sqldelight`, `store6-room`, `store6-compose` (~570 public declarations, approx. grep count 2026-08-06); fixes applied for every finding classed Missing, Stale, Contradictory, Misleading, or Unnecessary. + +- [ ] **Step 1: Audit, module by module (read-only).** For each public declaration (explicitApi mode means every intended-public symbol is spelled `public`), check its KDoc against the interface-documentation checklist: purpose beyond the name; inputs/outputs incl. nullability and defaults; errors callers must handle; side effects and lifecycle; ordering/concurrency/idempotency where the type is concurrent (flows, suspend functions: collection/completion/cancellation behavior — do not invent dispatcher or threading guarantees the code doesn't make). Also flag: signature narration (KDoc that restates names and types without use semantics — the finding class is Unnecessary), and the four commonMain files with zero KDoc found on 2026-08-06 (one each in `store6-core`, `store6-room`, `store6-testing`, `store6-devtools-inspector` — check intended-public status first; internal-only files may correctly have none). Record every finding as `file:line | class | evidence | remediation`. +- [ ] **Step 2: Fix recorded findings only.** No opportunistic edits outside the table. Missing → write the smallest complete contract; Unnecessary narration → delete; Stale/Contradictory → correct against code and tests, citing the evidence in the inventory row. +- [ ] **Step 3: Three-pass review** (from documentation-discipline) over the diff: (1) accuracy & protected content — every protected token and contract unchanged; (2) warrant — no unsupported claims or invented precision entered; (3) reader task — a caller can use each revised symbol without reading private internals. +- [ ] **Step 4: Verify** — `./gradlew apiCheck ktlintCheck` at the root scoped to the six modules (or per-module as in prior tasks); all `git diff --stat -- */api` empty; comment-only hunk review. +- [ ] **Step 5: Commit** — `git commit -m "docs: interface KDoc audit fixes for alpha01 artifacts"`. + +--- + +### Task 8: Final gate, Dokka proof, completion report + +**Files:** +- Modify: `docs/superpowers/plans/2026-08-06-store6-doc-cleanup-inventory.md` (completion report section) + +**Interfaces:** +- Consumes: everything. +- Produces: the branch ready for PR with a completion report at the required proof strength. + +- [ ] **Step 1: Full sweep gate** + +Run the Detection sweep and the classify-only pattern over all `store6-*/src`. Expected: zero hits for the Detection sweep; every remaining classify-only hit has an FP row in the inventory. + +- [ ] **Step 2: Full verification** + +```bash +./gradlew apiCheck ktlintCheck spotlessCheck +./gradlew :store6-core:dokkaHtml :store6-mutations:dokkaHtml :store6-testing:dokkaHtml :store6-room:dokkaHtml :store6-sqldelight:dokkaHtml :store6-compose:dokkaHtml +``` + +Expected: `BUILD SUCCESSFUL` for both. Dokka failures here mean a broken KDoc `[link]` — fix the link text only, re-run. Note: do **not** rerun any historical CI job as part of this plan; local verification plus the PR's own CI run is the evidence. + +- [ ] **Step 3: Full-branch diff review** + +`git diff main...HEAD -- 'store6-*/src'` — confirm every hunk is comment-only, one final time. `git diff main...HEAD --stat -- '*/api'` — empty. + +- [ ] **Step 4: Write the completion report** into the inventory file: files reviewed/changed; evidence used per kept-contract claim; commands run and results; unresolved `Unverifiable` rows; claims deleted for lack of evidence; proof strength = **"repository checks plus hunk review"** (no mechanical trivia-equivalence proof exists in this repo — do not claim stronger). + +- [ ] **Step 5: Commit and open PR** + +```bash +git add docs/superpowers/plans/2026-08-06-store6-doc-cleanup-inventory.md +git commit -m "docs: source-doc cleanup completion report" +git push -u origin docs/alpha01-source-doc-cleanup +gh pr create --title "docs: alpha01 source documentation cleanup" --body "$(cat <<'EOF' +Removes internal organizational context (issue IDs, decision tags, approval state, personal names, speculative future-work claims) from all store6-* KDoc and comments, and applies interface-doc audit fixes to the alpha01 public surface. Documentation-only: all api/ dumps byte-identical; apiCheck, ktlint, spotless, and per-module dokkaHtml green. Inventory and completion report: docs/superpowers/plans/2026-08-06-store6-doc-cleanup-inventory.md. + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +--- + +## Self-review notes + +- Every requirement of the request (internal-context removal, quality pass, alpha-readiness gate) maps to a task: disclosure → Tasks 2–6, quality → Task 7, proof → Task 8. +- Baseline counts and file lists are measured (2026-08-06), labeled as such, and every task re-enumerates at execution time instead of trusting them. +- The plan writes no rewrite text it has not verified: the four worked examples quote the current tree verbatim; all other rewrites are governed by the rubric plus a verify-before-keep rule, because pre-authoring 190+ rewrites without reading each surrounding declaration would bake in unverified contract claims. +- Known risk, mitigated: contract-bearing sentences entangled with provenance. Mitigation is the `Unverifiable` class (keep meaning, record) plus the apiCheck/empty-`api/`-diff/hunk-review boundary proof in every task. + +--- + +## Amendment A1 (2026-08-06, during execution): Sweep v2 + +Task 3 surfaced internal-referent families the Detection sweep does not match: design-doc section references (`engine-design §7`, `design §`), acceptance/test/criteria tags (`TEST-1`, `C-01`, `AC-3`, `OQ-5`), design-table rows (`row-7/8`), and bare zero-padded issue numbers (`017 residual-deadline repair`). Ruling: + +- **Sweep v2 (hard, zero-hit gate):** `engine-design|design §|§[0-9]+|\bTEST-[0-9]+\b|\bC-[0-9]{2}\b|\bAC-[0-9]+\b|\bOQ-[0-9]+\b|\brow-7/8\b` +- **Sweep v2 (classify-only, FPs recorded):** `\b0(0[1-9]|1[0-9]|2[0-9])\b|\bR[0-9]\b|\bT2E\b` +- **Task 3b (inserted):** apply v2 to `store6-core/src` (the only module whose v1 pass is complete). Includes the `seam/TransactionalSourceOfTruth.kt` second KDoc paragraph ("The row-7/8 direct-write optimization requires an extension-owned coordinated decorator…"): P3 speculative intent plus internal referent on published KDoc — delete the paragraph; the interface's own contract sentences stay. Also `StoreConformanceTest.kt` "(validator arrives in 004)" and remaining tagged comments. +- **Tasks 4–6:** each module gate = Detection sweep v1 + v2, both to zero (recorded FPs excepted). **Task 8:** full-tree gate = v1 + v2. +- **Known out-of-charter residue** (protected executable content; recorded in the completion report, not edited): internal shorthand inside string literals (e.g. an `error(...)` message in `SourceOfTruthHydrationRaceTest.kt`) and test-fixture data (`User("42", "Matt")`). +- `ktlintCheck` is a no-op for `store6-*` modules (root `build.gradle.kts` returns early); formatting assurance for this plan rests on `spotlessCheck` in Task 8 plus hunk review. Verification recipes' ktlint mentions are harmless but vacuous. diff --git a/docs/superpowers/plans/2026-08-06-store6-doc-cleanup-inventory.md b/docs/superpowers/plans/2026-08-06-store6-doc-cleanup-inventory.md new file mode 100644 index 000000000..080086c1a --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-store6-doc-cleanup-inventory.md @@ -0,0 +1,971 @@ +# Store6 alpha01 Source Documentation Cleanup — Inventory + +This inventory is the shared baseline and per-task tracking file for the `docs/alpha01-source-doc-cleanup` branch. Tasks 2-7 update the tables below in place as they classify and resolve hits; Task 8 appends the completion report. + +## Detection sweep (verbatim) + +Run from the repo root: + +```bash +grep -rEn 'TD-[0-9]+|RISK-[0-9]+|STORE-[0-9]+|FS-[0-9]+|RD-[0-9]+|\(D[0-9]+[a-z]?\)|[Ii]ssue [0-9]{2,3}|PROVISIONAL|pending [Ii]ssue|PR #[0-9]+|linear\.app|\bMatt\b|signs? off|sign-off' \ + --include='*.kt' --exclude-dir=build store6-*/src +``` + +A second, classify-only pattern (higher false-positive rate — every hit gets classified in the inventory, FPs are allowed to remain): `ruling|ruled|adopted shape|erratum`. + +Baseline capture command (this file's row set = Detection sweep ∪ classify-only pattern, sorted and deduplicated): + +```bash +grep -rEn 'TD-[0-9]+|RISK-[0-9]+|STORE-[0-9]+|FS-[0-9]+|RD-[0-9]+|\(D[0-9]+[a-z]?\)|[Ii]ssue [0-9]{2,3}|PROVISIONAL|pending [Ii]ssue|PR #[0-9]+|linear\.app|\bMatt\b|signs? off|sign-off' \ + --include='*.kt' --exclude-dir=build store6-*/src | sort > detection-sweep.txt +grep -rEn 'ruling|ruled|adopted shape|erratum' \ + --include='*.kt' --exclude-dir=build store6-*/src | sort > classify-only-sweep.txt +cat detection-sweep.txt classify-only-sweep.txt | sort -u > sweep-baseline.txt +wc -l sweep-baseline.txt +``` + +## Measured baseline counts (2026-08-06) + +- Detection sweep (ID classes + governance/speculative + personal-name/sign-off, combined single regex): **235 line hits**. +- Classify-only pattern (`ruling|ruled|adopted shape|erratum`): **30 line hits**. +- Overlap between the two patterns (same file:line matched by both): **3 line hits**. +- Combined, sorted, deduplicated baseline (235 + 30 − 3): **262 line hits**. +- Raw baseline file: `.superpowers/sdd/sweep-baseline.txt` (262 lines, this repo's worktree; not committed — regenerate with the commands above if needed). + +Per-module breakdown of the 262 combined hits: + +| module | hits | +| --- | --- | +| `store6-mutations` | 157 | +| `store6-core` | 48 | +| `store6-room` | 23 | +| `store6-testing` | 16 | +| `store6-benchmarks` | 5 | +| `store6-compose` | 4 | +| `store6-sqldelight` | 4 | +| `store6-compose-demo` | 2 | +| `store6-devtools` | 2 | +| `store6-mutations-testing` | 1 | +| **total** | **262** | + + +Note on the plan's stated expectation: the plan text (Global Constraints and Task 1 Step 2) was written from an earlier measurement ("65+132+32" / "130-200 lines") and explicitly says "re-measure, do not assume." The counts above are the actual 2026-08-06 re-measurement using the verbatim commands; they supersede the plan's placeholder numbers. + +--- + +## Task 2 — Delete the freeze-candidate stamp (cross-module, one pattern) + +Scope per the plan: every file matched by `grep -rln 'signs off' --include='*.kt' --exclude-dir=build store6-*/src`. The table below is the broader baseline subset matching Task 2's own sweep-clean check pattern (`\bMatt\b|signs? off|sign-off|issue 007`) — **44 hits** across 9 modules, wider than the plan's originally-guessed module list. Each row here is also present in its owning module's full table in the Task 3/5/6 sections below (intentional overlap — Task 2 resolves this specific stamp pattern first, cross-module; the owning module's task then re-sweeps for what remains). + +| file:line | excerpt | class (P1-P4/FP/Unverifiable) | action taken | +| --- | --- | --- | --- | +| `store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/SubscriptionChurnBenchmark.kt:21` | * collections — issue 007's OQ-5 explicitly deferred grace tuning (and retry-backoff shape) to | P3 | Deleted future-work clause ("issue 007's OQ-5 ... to first 016 data; this benchmark is that data's source"); kept the READER_PIPELINE_GRACE_MILLIS contract (verified: `store6-core/.../StoreLifecycle.kt:15`) and the raw-churn sentence. | +| `store6-compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/CollectAsState.kt:26` | * Closed-store behavior (finalized by issue 007): calling this on a closed store fails the | P2 | Deleted "(finalized by issue 007)" landing-state parenthetical (worked-example pattern from resolution); "Closed-store behavior:" contract sentence otherwise unchanged. | +| `store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/ClosedStoreBehaviorTest.kt:20` | * exact `Store.stream` seam they call. Close semantics were finalized by issue 007; the close | P2 | Deleted "Close semantics were finalized by issue 007; " clause; kept the OQ-3/ABI message-text contract. | +| `store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/Main.kt:12` | // Process-scoped store on the landed bounded-registry engine (issue 007): idle key engines | P1 | Deleted bare "(issue 007)" tag mid-sentence; bounded-registry/LRU contract unchanged. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/InMemorySourceOfTruth.kt:20` | * Canonical-key cells are intentionally unbounded until issue 007 adds their lifecycle policy. | P3 | Deleted "until issue 007 adds their lifecycle policy"; kept verified "intentionally unbounded" contract (no eviction/bound in the class body). | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/StoreResultFlows.kt:23` | * FS-1's O(1)-per-collector bound and closes the lifecycle-signal bound deferred to issue 007. | P2 | Deleted "deferred to issue 007"; kept the FS-1/lifecycle-signal-bound contract. The `FS-1` tag on this line is untouched, out of Task 2's pattern scope — left for Task 3's own sweep. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Bookkeeper.kt:33` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 | Deleted stamp sentence verbatim (worked example) + orphaned blank KDoc line; rest of KDoc unchanged. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Bookkeeper.kt:105` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 | Deleted stamp sentence verbatim (worked example) + orphaned blank KDoc line; rest of KDoc unchanged. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Fetcher.kt:15` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 | Deleted stamp sentence + preceding blank line, keeping the single blank separator before `@param` intact. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FreshnessValidator.kt:15` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 | Deleted stamp sentence verbatim (worked example) + orphaned blank KDoc line (`FreshnessContext` doc). | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FreshnessValidator.kt:30` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 | Deleted stamp sentence verbatim + orphaned blank KDoc line (`FreshnessValidator` interface doc). | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FreshnessValidator.kt:42` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 | Deleted stamp sentence verbatim + orphaned blank KDoc line (`FetchPlan` sealed interface doc). | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FreshnessValidator.kt:50` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 | Deleted stamp sentence verbatim + orphaned blank KDoc line (`FetchPlan.Skip` doc). | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FreshnessValidator.kt:57` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 | Deleted stamp sentence verbatim + orphaned blank KDoc line (`FetchPlan.Fetch` doc). | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FreshnessValidator.kt:66` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 | Deleted stamp sentence verbatim + orphaned blank KDoc line (`FetchPlan.Conditional` doc). | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/KeyEvents.kt:26` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may | P2 | Deleted 2-line wrapped stamp + orphaned blank KDoc line; rest of KDoc unchanged. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Overlay.kt:39` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes | P2 | Deleted 2-line wrapped stamp + orphaned blank KDoc line; rest of KDoc unchanged. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreResults.kt:15` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 | Deleted stamp sentence verbatim + orphaned blank KDoc line; rest of KDoc unchanged. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreRuntime.kt:13` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may | P2 | Deleted 2-line wrapped stamp + preceding blank line, keeping the blank separator before `@param` intact. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreTelemetry.kt:25` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may | P2 | Deleted 2-line wrapped stamp + orphaned blank KDoc line; rest of KDoc unchanged. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreWriteHandle.kt:12` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may | P2 | Deleted 2-line wrapped stamp (the brief's worked example) + preceding blank line, keeping the blank separator before `@param` intact. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/TransactionalSourceOfTruth.kt:24` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 | Deleted stamp sentence + preceding blank line, keeping the blank separator before `@param` intact. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/WallClock.kt:13` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 | Deleted stamp sentence verbatim + orphaned blank KDoc line; rest of KDoc unchanged. | +| `store6-devtools/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtools/StoreDevtoolsEvent.kt:13` | * decided: values never cross this seam. The seam remains a freeze candidate and sign-off is held. | P2 | Deleted "The seam remains a freeze candidate and sign-off is held." sentence; kept the v0-vocabulary/wire-format sentence. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationSourceOfTruth.kt:41` | * Cells are intentionally unbounded, matching the core default's posture until the issue 007 | P3 | Deleted "until the issue 007 lifecycle policy applies here"; kept "intentionally unbounded, matching the core default's posture" contract. | +| `store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/RoomBookkeeper.kt:41` | * This seam remains FREEZE CANDIDATE pending Matt signature. | P2 | Deleted stamp variant sentence + orphaned blank KDoc line; rest of KDoc unchanged. | +| `store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/RoomSourceOfTruth.kt:212` | * Freeze candidate: issue 007 has landed; the seam freezes only after Matt signs the prepared | P2 | Deleted 2-line stamp variant ("Freeze candidate: issue 007 has landed ... sign-off package.") + orphaned blank KDoc line; rest of KDoc unchanged. | +| `store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/RoomSourceOfTruth.kt:213` | * sign-off package. | P2 | Same edit as :212 (continuation line of the same stamp sentence). | +| `store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/Store6BookkeepingEntity.kt:14` | * This surface is a seam freeze candidate pending Matt's signature. | P2 | Deleted stamp variant sentence + orphaned blank KDoc line; rest of KDoc unchanged. | +| `store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightBookkeeper.kt:35` | * This seam remains FREEZE CANDIDATE awaiting Matt signature. | P2 | Deleted stamp variant sentence + orphaned blank KDoc line; rest of KDoc unchanged. | +| `store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightSourceOfTruth.kt:62` | * Seam status: FREEZE CANDIDATE awaiting Matt signature; never frozen. | P2 | Deleted stamp variant sentence + preceding blank line, keeping the blank separator before `@param` intact. | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStore.kt:40` | * Invalidation implements Decision #37 (Matt, 2026-07-20): it is a stale-mark only and never | P2 | Deleted "Decision #37 (Matt, 2026-07-20): " provenance prefix; kept "invalidation is a stale-mark only and never consumes a script" contract. | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStore.kt:59` | * The seam consumed here is a FREEZE CANDIDATE, not frozen: freeze sign-off remains held until | P2 | Deleted stamp sentence ("The seam consumed here is a FREEZE CANDIDATE ... Matt signs off."); kept the following close()-behavior sentences (same edit as :60/:63). | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStore.kt:60` | * issue 007 lands and Matt signs off. [close] is synchronous and idempotent. Active collectors are | P2 | Same edit as :59 (continuation of the stamp sentence; "[close] is synchronous..." onward kept). | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStore.kt:63` | * text were finalized by issue 007 against the engine's close lifecycle. | P2 | Rewrote "text were finalized by issue 007 against the engine's close lifecycle" to "text are pinned against the engine's close lifecycle" (same edit as :59/:60), keeping the contract that these details are pinned/verified against engine behavior. | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStore.kt:274` | * Close semantics finalized by issue 007. | P2 | Deleted "Close semantics finalized by issue 007." line; kept "Closes this fake synchronously and idempotently." | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStore.kt:437` | * Decision #37 (ruled by Matt, 2026-07-20): invalidate is a stale-mark only, the engine's | P2 | Deleted "Decision #37 (ruled by Matt, 2026-07-20): " provenance prefix; kept the epoch-bump-analog contract. | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStore.kt:487` | // Finalized by issue 007: core keeps STORE_CLOSED_MESSAGE internal by design (FS-5 — | P2 | Deleted "Finalized by issue 007: " prefix; kept the STORE_CLOSED_MESSAGE/FS-5 rationale. The `FS-5` tag on this line is untouched, out of Task 2's pattern scope — left for Task 6's own sweep. | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeStoreConformanceTest.kt:32` | * There is no invalidate-divergence row: Decision #37 (Matt, 2026-07-20) aligned the fake to the | P2 | Deleted "Decision #37 (Matt, 2026-07-20)" provenance subject; rewrote to "the fake is aligned to..." keeping the stale-mark-only/next-demand contract. | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeStoreConformanceTest.kt:191` | // THE Decision #37 alignment pin (ruled by Matt, 2026-07-20): with NO active demand, | P2 | Deleted "THE Decision #37 alignment pin (ruled by Matt, 2026-07-20): " prefix; kept "With NO active demand, invalidate defers..." contract (rest of the comment unchanged). | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeStoreConformanceTest.kt:375` | // Finalized by issue 007: pins verified against StoreCloseLifecycleTest in store6-core. | P2 | Deleted "Finalized by issue 007: " prefix; kept "Pins verified against StoreCloseLifecycleTest in store6-core." | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeStoreConformanceTest.kt:386` | // Finalized by issue 007: pins verified against StoreCloseLifecycleTest in store6-core. | P2 | Deleted "Finalized by issue 007: " prefix (second, distinct occurrence in `close_cancelsActiveCollectors`); kept "Pins verified against StoreCloseLifecycleTest in store6-core." | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/UserViewModelSampleTest.kt:45` | fake.enqueueFetchValue(key, User("42", "Matt")) | FP | No change. `"Matt"` is a test-fixture `User.name` string literal (protected code/data value), not documentation or a governance/provenance reference — coincidental regex match. Flagged in the task report as a deliberate deviation from the literal "gate returns zero" wording, since altering test data is out of this task's charter. | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/UserViewModelSampleTest.kt:49` | assertEquals("Matt", assertIs(awaitItem()).name) | FP | No change (same rationale as :45 — asserts against the same test-fixture value). | + +--- + +## Task 3 — store6-core remaining internal references + +Scope: all baseline hits in `store6-core/src` (full per-module list; Task 2 above resolves the `signs off` stamp subset first, then Task 3 sweeps `store6-core/src` again for what remains). + +Task 3 result: both sweeps return literal zero for `store6-core/src` (main Detection pattern and the classify-only `ruling|ruled|adopted shape|erratum` pattern). No FP rows were needed in this module. + +Two same-comment referents that the sweep regex does not match were removed with their flagged lines, because they sit inside the same comment block and leaving them would have produced incoherent prose: the `017 residual-deadline repair:` prefix (12 occurrences, always line 1 of the two-line Turbine-deadline comment) and `(017 post-merge)` in `KeyEnginePlanningTest.kt:289`. + +Out-of-sweep referents observed in `store6-core/src` and **deliberately left unchanged** (no sweep pattern matches them, so no later task will catch them either — flagged here for a controller ruling, candidates for a Task 8 addendum): `engine-design §7` (`KeyRegistry.kt:12`, `KeyRegistry.kt:112`, `StoreLifecycle.kt:11`), `Per engine-design R3` (`StoreResultFlows.kt:25`), `The row-7/8 direct-write optimization` (`TransactionalSourceOfTruth.kt:12`), `THE 001 acceptance test … TEST-1 emission-sequence seed` (`StoreConformanceTest.kt:26`), `C-01/C-02 seed` (`StoreConformanceTest.kt:64`), `the 001 get-posture … (validator arrives in 004)` (`StoreConformanceTest.kt:89`), `C-04/C-05/C-06 shape` (`SourceOfTruthConformanceTest.kt:33`, `:72`, `:110`), `TEST-7` (`StoreTelemetryTest.kt:114`), `005 must not emit Revalidated` (`SourceOfTruthHydrationRaceTest.kt:97`), `AC-3 (TEST-1)` (`StoreInvalidationConformanceTest.kt:28`), `C-12 seed` (`StoreInvalidationConformanceTest.kt:285`). + + +#### `store6-core` (48 hits at baseline) + +| file:line | excerpt | class (P1-P4/FP/Unverifiable) | action taken | +| --- | --- | --- | --- | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/InMemorySourceOfTruth.kt:20` | * Canonical-key cells are intentionally unbounded until issue 007 adds their lifecycle policy. | P3 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyRegistry.kt:21` | * bookkeeper, so a recreated engine is semantically identical (issue 006's hydration | P4 | Rewrote the provenance rationale as the mechanism: `(issue 006's hydration stamping)` -> "hydration restamps freshness from the bookkeeper's persisted status". Verified against `KeyEngine.hydrateFromSot` (KeyEngine.kt:2147-2184: reads `bookkeeper.status(key)`, derives `hydratedMeta` and `staleEpochAtCommit`). Recreation-identity claim pinned by `org.mobilenativefoundation.store6.core.StoreEvictionConformanceTest.evictedEngine_recreation_semanticallyInvisible` and `org.mobilenativefoundation.store6.core.StoreEvictionConformanceTest.memoryCache_neverDivergesFromDurableTruth`. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyRegistry.kt:27` | * - Creation still runs [verifyStableCanonicalId] once per residency (FS-6). | P1 | Deleted the ` (FS-6)` token; sentence otherwise unchanged. Claim verified at KeyRegistry.kt:162 (`newEngine` calls `verifyStableCanonicalId`) and pinned by `org.mobilenativefoundation.store6.core.PublicSurfaceTest.unstableCanonicalId_failsFastNamingTheFix`. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/StoreResultFlows.kt:23` | * FS-1's O(1)-per-collector bound and closes the lifecycle-signal bound deferred to issue 007. | P1 | Task 2 removed "deferred to issue 007"; this task replaced `FS-1's O(1)-per-collector bound` with `an O(1)-per-collector bound`. Both surviving claims pinned by `org.mobilenativefoundation.store6.core.internal.ConflateLatestDataTest.blockedCollector_queueBoundedAcrossManyRevalidationCycles` (kind-bounded queue) and `org.mobilenativefoundation.store6.core.internal.ConflateLatestDataTest.slowCollector_getsLatestDataAndEveryLifecycleSignalBeforeCompletion` (lifecycle-signal bound). | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/ValueEnvelope.kt:14` | * FS-6 conservative posture: the value reports `isStale = true`, age zero, and never | P1 | Deleted the `FS-6 ` token from "the FS-6 conservative posture"; the contract (`isStale = true`, age zero, never satisfies demand without a revalidation) is unchanged. Pinned by `org.mobilenativefoundation.store6.core.SourceOfTruthConformanceTest.cachedOrFetch_prePopulatedSot_streamServesSotBeforeRevalidation` (asserts `durable.isStale`) and `org.mobilenativefoundation.store6.core.SourceOfTruthConformanceTest.cachedOrFetch_hydratedRow_servesThenRevalidatesExactlyOnce`. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Bookkeeper.kt:105` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Bookkeeper.kt:33` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Fetcher.kt:15` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FreshnessValidator.kt:15` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FreshnessValidator.kt:30` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FreshnessValidator.kt:42` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FreshnessValidator.kt:50` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FreshnessValidator.kt:57` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FreshnessValidator.kt:66` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/KeyEvents.kt:26` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Overlay.kt:39` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreResults.kt:15` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreRuntime.kt:13` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreTelemetry.kt:25` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreWriteHandle.kt:12` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail (brief's worked example). | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/TransactionalSourceOfTruth.kt:24` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/TransactionalSourceOfTruth.kt:8` | * Optional atomicity capability for a [SourceOfTruth] (TD-11). Detectable via | P1 | Deleted the ` (TD-11)` token; sentence otherwise unchanged (brief's worked example). The "engine never assumes it / no silent non-atomic default" clause is verified by absence: `grep -rn 'is TransactionalSourceOfTruth' store6-core/src` returns no engine-side check; the only implementors are `RoomSourceOfTruth` and `SqlDelightSourceOfTruth`. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/WallClock.kt:13` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/EmissionSequenceConformanceTest.kt:242` | // T2E ruling: a cold-baseline 304 commits ObsoleteRevalidation and legally | P1 | Deleted the `T2E ruling:` attribution label; the contract sentence survives verbatim in meaning (rewrapped). Pinned by `org.mobilenativefoundation.store6.core.internal.KeyEnginePlanningTest.coldBaselineNotModified_hydratedBeforeCommit_classifiesObsoleteAndReplans` and by the enclosing test's own third-call arm. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/EmissionSequenceConformanceTest.kt:319` | // Turbine deadline above the shadow so runTest provides the only effective timeout (D0, PR #15). | P1 | Deleted the `(D0, PR #15)` token and, in the same two-line comment, the leading `017 residual-deadline repair:` attribution; rewrapped to two lines. The rationale (Turbine's 3s default nesting inside the 25s shadow) is unchanged and verified: Turbine 1.2.0 `DEFAULT_TIMEOUT = 3000.milliseconds` (coroutines.kt:26) against the adjacent `TEST_TIMEOUT = 25.seconds` / `TURBINE_DEADLINE = 30.seconds`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/FreshnessPolicyConformanceTest.kt:490` | // Turbine deadline above the shadow so runTest provides the only effective timeout (D0, PR #15). | P1 | Deleted the `(D0, PR #15)` token and, in the same two-line comment, the leading `017 residual-deadline repair:` attribution; rewrapped to two lines. The rationale (Turbine's 3s default nesting inside the 25s shadow) is unchanged and verified: Turbine 1.2.0 `DEFAULT_TIMEOUT = 3000.milliseconds` (coroutines.kt:26) against the adjacent `TEST_TIMEOUT = 25.seconds` / `TURBINE_DEADLINE = 30.seconds`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/KeyEngineSourceOfTruthRaceTest.kt:648` | // Turbine deadline above the shadow so runTest provides the only effective timeout (D0, PR #15). | P1 | Deleted the `(D0, PR #15)` token and, in the same two-line comment, the leading `017 residual-deadline repair:` attribution; rewrapped to two lines. The rationale (Turbine's 3s default nesting inside the 25s shadow) is unchanged and verified: Turbine 1.2.0 `DEFAULT_TIMEOUT = 3000.milliseconds` (coroutines.kt:26) against the adjacent `TEST_TIMEOUT = 25.seconds` / `TURBINE_DEADLINE = 30.seconds`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/PublicSurfaceTest.kt:15` | /** The FS-6 detector: an unstable canonicalId fails fast and names the fix. */ | P1 | `The FS-6 detector:` -> `Detector fixture:`; the rest of the fixture KDoc is unchanged. The behavior it labels is pinned by `org.mobilenativefoundation.store6.core.PublicSurfaceTest.unstableCanonicalId_failsFastNamingTheFix` (same file). | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/ReaderGenerationRecoveryTest.kt:134` | // Turbine deadline above the shadow so runTest provides the only effective timeout (D0, PR #15). | P1 | Deleted the `(D0, PR #15)` token and, in the same two-line comment, the leading `017 residual-deadline repair:` attribution; rewrapped to two lines. The rationale (Turbine's 3s default nesting inside the 25s shadow) is unchanged and verified: Turbine 1.2.0 `DEFAULT_TIMEOUT = 3000.milliseconds` (coroutines.kt:26) against the adjacent `TEST_TIMEOUT = 25.seconds` / `TURBINE_DEADLINE = 30.seconds`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthBindingConformanceTest.kt:2073` | // Turbine deadline above the shadow so runTest provides the only effective timeout (D0, PR #15). | P1 | Deleted the `(D0, PR #15)` token and, in the same two-line comment, the leading `017 residual-deadline repair:` attribution; rewrapped to two lines. The rationale (Turbine's 3s default nesting inside the 25s shadow) is unchanged and verified: Turbine 1.2.0 `DEFAULT_TIMEOUT = 3000.milliseconds` (coroutines.kt:26) against the adjacent `TEST_TIMEOUT = 25.seconds` / `TURBINE_DEADLINE = 30.seconds`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthCancellationConformanceTest.kt:537` | // Turbine deadline above the shadow so runTest provides the only effective timeout (D0, PR #15). | P1 | Deleted the `(D0, PR #15)` token and, in the same two-line comment, the leading `017 residual-deadline repair:` attribution; rewrapped to two lines. The rationale (Turbine's 3s default nesting inside the 25s shadow) is unchanged and verified: Turbine 1.2.0 `DEFAULT_TIMEOUT = 3000.milliseconds` (coroutines.kt:26) against the adjacent `TEST_TIMEOUT = 25.seconds` / `TURBINE_DEADLINE = 30.seconds`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthConformanceTest.kt:353` | // FS-6 + hydration: unknown provenance serves and triggers exactly one revalidation. | P1 | `FS-6 + hydration:` -> `Hydration:`; claim unchanged. It labels, and is pinned by, `org.mobilenativefoundation.store6.core.SourceOfTruthConformanceTest.cachedOrFetch_hydratedRow_servesThenRevalidatesExactlyOnce`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthConformanceTest.kt:394` | // FS-1: persisted truth participates in startup before its revalidation can overwrite it. | P1 | Deleted the `FS-1: ` prefix and capitalized; claim unchanged. It labels, and is pinned by, `org.mobilenativefoundation.store6.core.SourceOfTruthConformanceTest.cachedOrFetch_prePopulatedSot_streamServesSotBeforeRevalidation`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthConformanceTest.kt:489` | // Turbine deadline above the shadow so runTest provides the only effective timeout (D0, PR #15). | P1 | Deleted the `(D0, PR #15)` token and, in the same two-line comment, the leading `017 residual-deadline repair:` attribution; rewrapped to two lines. The rationale (Turbine's 3s default nesting inside the 25s shadow) is unchanged and verified: Turbine 1.2.0 `DEFAULT_TIMEOUT = 3000.milliseconds` (coroutines.kt:26) against the adjacent `TEST_TIMEOUT = 25.seconds` / `TURBINE_DEADLINE = 30.seconds`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConformanceTest.kt:269` | // Turbine deadline above the shadow so runTest provides the only effective timeout (D0, PR #15). | P1 | Deleted the `(D0, PR #15)` token and, in the same two-line comment, the leading `017 residual-deadline repair:` attribution; rewrapped to two lines. The rationale (Turbine's 3s default nesting inside the 25s shadow) is unchanged and verified: Turbine 1.2.0 `DEFAULT_TIMEOUT = 3000.milliseconds` (coroutines.kt:26) against the adjacent `TEST_TIMEOUT = 25.seconds` / `TURBINE_DEADLINE = 30.seconds`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConformanceTest.kt:35` | expectNoEvents() // live, not completed (FS-1) | P1 | Deleted the ` (FS-1)` token; the inline comment `// live, not completed` still describes the adjacent `expectNoEvents()` assertion in `org.mobilenativefoundation.store6.core.StoreConformanceTest.coldStream_noCachedValue_emitsLoadingThenDataFromFetcher`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConformanceTest.kt:41` | // (b1) fetcher throws -> stream emits Error and stays live (FS-5: stream never throws) | P1 | `(FS-5: stream never throws)` -> `(the stream never throws)`; the claim survives verbatim in meaning and is pinned by the test it labels, `org.mobilenativefoundation.store6.core.StoreConformanceTest.fetcherThrows_streamEmitsErrorAndStaysLive`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConformanceTest.kt:55` | // (b2) fetcher throws -> get throws StoreException carrying StoreError.Fetch (FS-2/FS-5) | P1 | Deleted the ` (FS-2/FS-5)` token; the label still names the contract, pinned by `org.mobilenativefoundation.store6.core.StoreConformanceTest.fetcherThrows_getThrowsStoreException`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreInvalidationConformanceTest.kt:318` | assertTrue(exception.message!!.contains("test/1")) // FS-5: which key | P1 | Deleted the `FS-5: ` prefix (`// which key`); the comment still annotates its own `assertTrue(exception.message!!.contains("test/1"))` assertion in `org.mobilenativefoundation.store6.core.StoreInvalidationConformanceTest.clearDuringInFlightFetch_commitDiscarded_noResurrection`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreInvalidationConformanceTest.kt:319` | assertTrue(exception.message!!.contains("clear")) // FS-5: what happened | P1 | Deleted the `FS-5: ` prefix (`// what happened`); same enclosing test and same self-pinning assertion pattern as :318. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreInvalidationConformanceTest.kt:655` | // 006 fenced-clear ruling: an already-active pipeline may queue one duplicate | P1 | `006 fenced-clear ruling:` -> `Fenced clear:` (internal issue number and ruling language removed; the mechanism name `fenced clear` is repository vocabulary, see KeyRegistry.kt:23-26 "double-sweep-under-fence"). The two-sentence contract is unchanged and is pinned by the enclosing test `org.mobilenativefoundation.store6.core.StoreInvalidationConformanceTest.clearNamespace_activeLocalOnlyStreamObservesMissingWithoutRefetch`, which bounds queued pre-clear replays at 1 and then asserts Loading followed by Missing. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreInvalidationConformanceTest.kt:825` | // Turbine deadline above the shadow so runTest provides the only effective timeout (D0, PR #15). | P1 | Deleted the `(D0, PR #15)` token and, in the same two-line comment, the leading `017 residual-deadline repair:` attribution; rewrapped to two lines. The rationale (Turbine's 3s default nesting inside the 25s shadow) is unchanged and verified: Turbine 1.2.0 `DEFAULT_TIMEOUT = 3000.milliseconds` (coroutines.kt:26) against the adjacent `TEST_TIMEOUT = 25.seconds` / `TURBINE_DEADLINE = 30.seconds`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreRevalidationConformanceTest.kt:102` | // T2E ruling: a cold-baseline 304 commits ObsoleteRevalidation and legally | P1 | Deleted the `T2E ruling:` attribution label; the contract sentence survives verbatim in meaning (rewrapped). Pinned by `org.mobilenativefoundation.store6.core.internal.KeyEnginePlanningTest.coldBaselineNotModified_hydratedBeforeCommit_classifiesObsoleteAndReplans` and by the enclosing test's own third-call arm. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreRevalidationConformanceTest.kt:224` | // Turbine deadline above the shadow so runTest provides the only effective timeout (D0, PR #15). | P1 | Deleted the `(D0, PR #15)` token and, in the same two-line comment, the leading `017 residual-deadline repair:` attribution; rewrapped to two lines. The rationale (Turbine's 3s default nesting inside the 25s shadow) is unchanged and verified: Turbine 1.2.0 `DEFAULT_TIMEOUT = 3000.milliseconds` (coroutines.kt:26) against the adjacent `TEST_TIMEOUT = 25.seconds` / `TURBINE_DEADLINE = 30.seconds`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreRevalidationConformanceTest.kt:34` | // T2E ruling: a cold-baseline 304 commits ObsoleteRevalidation and legally | P1 | Deleted the `T2E ruling:` attribution label; the contract sentence survives verbatim in meaning (rewrapped). Pinned by `org.mobilenativefoundation.store6.core.internal.KeyEnginePlanningTest.coldBaselineNotModified_hydratedBeforeCommit_classifiesObsoleteAndReplans` and by the enclosing test's own third-call arm. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreRuntimeTest.kt:156` | // Turbine deadline above the shadow so runTest provides the only effective timeout (D0, PR #15). | P1 | Deleted the `(D0, PR #15)` token and, in the same two-line comment, the leading `017 residual-deadline repair:` attribution; rewrapped to two lines. The rationale (Turbine's 3s default nesting inside the 25s shadow) is unchanged and verified: Turbine 1.2.0 `DEFAULT_TIMEOUT = 3000.milliseconds` (coroutines.kt:26) against the adjacent `TEST_TIMEOUT = 25.seconds` / `TURBINE_DEADLINE = 30.seconds`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/KeyEnginePlanningTest.kt:289` | // T2E ruling (017 post-merge): a 304 that launched against a null residence baseline but | P1 | Deleted the `T2E ruling (017 post-merge):` attribution label and rewrapped; all three contract clauses (obsolete launch snapshot, not an adapter-contract violation, classify ObsoleteRevalidation and replan once) survive verbatim in meaning. Pinned by the test it labels, `org.mobilenativefoundation.store6.core.internal.KeyEnginePlanningTest.coldBaselineNotModified_hydratedBeforeCommit_classifiesObsoleteAndReplans` (asserts "the cold-baseline 304 self-heal replans exactly once"). | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/OverlayProjectionProtocolTest.kt:1740` | // Turbine deadline above the shadow so runTest provides the only effective timeout (D0, PR #15). | P1 | Deleted the `(D0, PR #15)` token and, in the same two-line comment, the leading `017 residual-deadline repair:` attribution; rewrapped to two lines. The rationale (Turbine's 3s default nesting inside the 25s shadow) is unchanged and verified: Turbine 1.2.0 `DEFAULT_TIMEOUT = 3000.milliseconds` (coroutines.kt:26) against the adjacent `TEST_TIMEOUT = 25.seconds` / `TURBINE_DEADLINE = 30.seconds`. | + +--- + +## Task 3b — store6-core, Sweep v2 + +Inserted by plan Amendment A1. Task 3 cleared `store6-core/src` against Sweep v1; this task clears the same module against the widened Sweep v2, and discharges every referent Task 3 recorded above as "deliberately left unchanged" (the paragraph at the head of the Task 3 section). + +Sweep v2 patterns, scoped to `store6-core/src`, `--include='*.kt' --exclude-dir=build`: + +- **hard (zero-hit gate):** `engine-design|design §|§[0-9]+|\bTEST-[0-9]+\b|\bC-[0-9]{2}\b|\bAC-[0-9]+\b|\bOQ-[0-9]+\b|\brow-7/8\b` — **14 hits before, 0 after.** +- **classify-only (FPs may remain):** `\b0(0[1-9]|1[0-9]|2[0-9])\b|\bR[0-9]\b|\bT2E\b` — **8 hits before, 2 after** (both recorded FP below). `T2E` matched nothing (Task 3 had already removed the `T2E ruling:` labels). + +Two source lines matched both patterns (`StoreResultFlows.kt:25`, `StoreConformanceTest.kt:26`), so 22 rows cover 20 distinct lines across 10 files. Class counts over the 20 distinct lines: **P1 12, P3 4, P4 2, FP 2, Unverifiable 0** (Task 6, part B item 5 relabeled `StoreTelemetryTest.kt:114` from P1 to P3 — see that row below). `file:line` is the pre-edit (enumeration-time) location. Sweep v1 re-run after these edits still returns zero for `store6-core/src`. + +#### `store6-core` — hard pattern (14 hits) + +| file:line | excerpt | class (P1-P4/FP/Unverifiable) | action taken | +| --- | --- | --- | --- | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyRegistry.kt:12` | * Structure (engine-design §7, reconciled with the landed registry): | P1 | Deleted the whole parenthetical (design-doc referent plus its landing-state qualifier `reconciled with the landed registry`), leaving `* Structure:`. The bullet list it introduces is untouched; its claims are pinned by `org.mobilenativefoundation.store6.core.internal.KeyRegistryTest.release_atZeroRefs_parksQuiescentEngineInIdle`, `.idleOverflow_evictsEldestOnly`, `.sweep_retainsEngines_andReleasesAfterAction` and `.counters_createdMinusDestroyed_equalsResident`. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyRegistry.kt:112` | // release re-runs this check; leaving it active is the engine-design §7 shape. | P4 | Rationale was expressed only via the design-doc referent. Restated from the code: `; leaving it active is the engine-design §7 shape.` -> `, so it deliberately stays active until then.` Verified at KeyRegistry.kt:113 (`if (!handle.engine.isQuiescentForIdle()) return@withLock emptyList()` returns before `active.remove(id)`). Pinned by `org.mobilenativefoundation.store6.core.internal.KeyRegistryTest.fetchResidencyHook_pinsEngineAcrossCallerRelease` and `.fetchJob_pinsResidencyAfterLastWaiterCancels_untilCommitSettles`. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/StoreResultFlows.kt:25` | * Per engine-design R3, a pathological fetch-error storm cannot grow a collector's buffer because | P1 | Deleted the `Per engine-design R3, ` prefix and capitalized; paragraph rewrapped, wording otherwise identical. Also clears this line's classify-only `R3` hit. Pinned by `org.mobilenativefoundation.store6.core.internal.ConflateLatestDataTest.blockedCollector_queueBoundedAcrossManyRevalidationCycles`. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/FreshnessValidator.kt:43` | * The zero-configuration AC-6 seed policy table. | P1 | Deleted `AC-6 seed` (the acceptance-criterion tag and the internal `seed` shorthand that binds to it), leaving `* The zero-configuration policy table.` The `DefaultFreshnessValidator` table itself and the following clamping sentence are unchanged; pinned by `org.mobilenativefoundation.store6.core.internal.FreshnessValidatorTest` (whole class, one test per `Freshness` arm) and `org.mobilenativefoundation.store6.core.StoreDefaultsPinTest.defaultFreshness_isCachedOrFetch_zeroConfig`. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/StoreLifecycle.kt:11` | /** Default bound on quiescent engine residency (engine-design §7; StoreBuilder.maxIdleKeys). */ | P1 | Deleted the `engine-design §7; ` referent; kept the `StoreBuilder.maxIdleKeys` cross-reference, verified to exist at StoreBuilder.kt:63 (`private var maxIdleKeys: Int = DEFAULT_MAX_IDLE_ENGINES`) and StoreBuilder.kt:119. The `128` value is untouched and pinned by `org.mobilenativefoundation.store6.core.StoreZeroConfigEquivalenceTest` (explicit `maxIdleKeys(128)` equivalence arms). | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/TransactionalSourceOfTruth.kt:12` | * The row-7/8 direct-write optimization requires an extension-owned coordinated decorator | P3 | Pre-ruled: deleted the entire second KDoc paragraph (10 lines) — a design-table referent (`row-7/8`) plus a prescription of an extension-side decorator protocol, on published `@ExperimentalStoreApi` KDoc. Kept every contract sentence of the interface itself: the first paragraph (optional atomicity capability, `sot is TransactionalSourceOfTruth` detection, engine never assumes it, no silent non-atomic default), the trailing `StoreWriteHandle.confirmFresh` sentence, and both `@param` lines. See the Task 3b concerns note below: the deleted protocol *is* implemented, by the unpublished `store6-extension-probe`, whose own KDoc already documents it in terms of its own components. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthConformanceTest.kt:33` | // C-05 shape: values arriving via fetch commit are attributed FETCHER. | P1 | Deleted the `C-05 shape: ` prefix and capitalized; the claim is unchanged and is pinned by the test it labels, `org.mobilenativefoundation.store6.core.SourceOfTruthConformanceTest.originHonesty_fetchCommit_emitsFetcher` (asserts `Origin.FETCHER`). | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthConformanceTest.kt:72` | // C-06 shape: external data is delivered as SOT/stale before its one active-demand revalidation. | P1 | Deleted the `C-06 shape: ` prefix and capitalized; the claim is unchanged and is pinned by the test it labels, `org.mobilenativefoundation.store6.core.SourceOfTruthConformanceTest.originHonesty_externalSotWrite_emitsSotToActiveStream` (asserts `Origin.SOT`, `isStale`, and exactly two fetch calls). | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthConformanceTest.kt:110` | // C-04 shape: the memory fast path serves without waiting for the pipeline, stamped MEMORY. | P1 | Deleted the `C-04 shape: ` prefix and capitalized; the claim is unchanged and is pinned by the test it labels, `org.mobilenativefoundation.store6.core.SourceOfTruthConformanceTest.originHonesty_memoryFastPath_reStampsMemory` (asserts `Origin.MEMORY`). | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreTelemetryTest.kt:114` | // Allocation-count measurement is deferred to store6-benchmarks (TEST-7). | P3 | Relabeled (Task 6, part B item 5 — was misclassed P1; the edit itself is P3-shaped, replacing the future/deferral framing `is deferred to` with `lives in`, not a bare-tag deletion). Deleted the `(TEST-7)` tag; also corrected the stale future framing `is deferred to` -> `lives in`, verified against `store6-benchmarks/src/test/kotlin/org/mobilenativefoundation/store6/benchmarks/TelemetryAllocationProbe.kt` (`org.mobilenativefoundation.store6.benchmarks.TelemetryAllocationProbe.residentServe_callerThreadAllocationDelta_reported`), which already performs that measurement and cross-references `StoreTelemetryTest.kt:114`. One line replaced one line, so that cross-reference's line number still resolves. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConformanceTest.kt:26` | // (a) THE 001 acceptance test — cold stream: Loading then Data(origin=FETCHER). TEST-1 emission-sequence seed. | P1 | Deleted both internal referents (`001`, `TEST-1 emission-sequence seed`), leaving `// (a) the cold-stream acceptance test: Loading then Data(origin=FETCHER)` in the lowercase, no-trailing-period style of the sibling `(b1)`/`(c)` labels. Also clears this line's classify-only `001` hit. Pinned by the test it labels, `org.mobilenativefoundation.store6.core.StoreConformanceTest.coldStream_noCachedValue_emitsLoadingThenDataFromFetcher`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConformanceTest.kt:64` | // (c) single-flight smoke: two concurrent collectors, one fetcher invocation. C-01/C-02 seed. | P1 | Deleted the trailing ` C-01/C-02 seed.` token; the label is otherwise unchanged and is pinned by the test it labels, `org.mobilenativefoundation.store6.core.StoreConformanceTest.twoConcurrentCollectors_singleFetcherInvocation` (asserts `1` fetcher call across two collectors). | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreInvalidationConformanceTest.kt:28` | // AC-3 (TEST-1): an active stream signaled by invalidate observes refetched data. | P1 | Deleted the `AC-3 (TEST-1): ` prefix and capitalized; the claim is unchanged and is pinned by the test it labels, `org.mobilenativefoundation.store6.core.StoreInvalidationConformanceTest.invalidate_activeStream_observesRefetchedData`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreInvalidationConformanceTest.kt:285` | // C-12 seed: clear during an in-flight fetch discards the commit; no resurrection. | P1 | Deleted the `C-12 seed: ` prefix and capitalized; the claim is unchanged and is pinned by the test it labels, `org.mobilenativefoundation.store6.core.StoreInvalidationConformanceTest.clearDuringInFlightFetch_commitDiscarded_noResurrection`. | + +#### `store6-core` — classify-only pattern (8 hits) + +| file:line | excerpt | class (P1-P4/FP/Unverifiable) | action taken | +| --- | --- | --- | --- | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/StoreResultFlows.kt:25` | * Per engine-design R3, a pathological fetch-error storm cannot grow a collector's buffer because | P1 | `R3` (design-doc requirement tag). Same line and same edit as the hard-pattern row above — see it for detail and pinning test. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/StoreLifecycle.kt:19` | * to the benchmarks-informed cycle (016) — see decisions/007-memory-boundedness.md. | P3 | Future-work claim carrying an internal issue number and a dangling internal doc path (`decisions/007-memory-boundedness.md` does not exist anywhere in the tree — `find` for `*memory-boundedness*` returns nothing, and there is no `decisions/` directory). Deleted `; tuned backoff is deferred to the benchmarks-informed cycle (016) — see decisions/007-memory-boundedness.md`, collapsing the block to `/** Fixed defensive delay before retrying a failed reader subscription. */`. No contract removed: the surviving sentence plus the untouched `100L` value are the whole documented fact. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthConformanceTest.kt:223` | // R7: resubscribe after clear may duplicate, never lose; cleared value never replays. | P1 | Deleted the `R7: ` design-doc requirement tag and capitalized; both clauses are unchanged and are pinned by the test they label, `org.mobilenativefoundation.store6.core.SourceOfTruthConformanceTest.resubscribeAfterClear_duplicatesNotLosses`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConformanceTest.kt:26` | // (a) THE 001 acceptance test — cold stream: Loading then Data(origin=FETCHER). TEST-1 emission-sequence seed. | P1 | `001` (bare zero-padded issue number). Same line and same edit as the hard-pattern row above — see it for detail and pinning test. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConformanceTest.kt:89` | // (d) pins the 001 get-posture: a resident value is served without a refetch (validator arrives in 004) | P3 | Pre-ruled. Deleted the future-work claim `(validator arrives in 004)` and replaced the internal referent `the 001 get-posture` with self-contained phrasing read off the test: `// (d) pins get's posture: a resident value is served without a refetch`. The behavioral half of the label was already self-contained and is unchanged. Pinned by the test it labels, `org.mobilenativefoundation.store6.core.StoreConformanceTest.getAfterStreamCommitted_servesResidentValueWithoutRefetch` (asserts the resident `"v1"` and `1` total fetch call). | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthHydrationRaceTest.kt:97` | is StoreResult.Revalidated -> error("005 must not emit Revalidated.") | FP | Executable string, out of doc-pass charter. Pre-ruled protected content: the `005` sits inside an `error(...)` argument in the `when` arm of `org.mobilenativefoundation.store6.core.SourceOfTruthHydrationRaceTest.externalAbsentObservedDuringHydration_neverResurrectsSnapshot`. No change. Recorded as known out-of-charter residue. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreDurableMaintenanceConformanceTest.kt:15` | * work for issues 010/011. | P4 | Scope note whose rationale existed only as internal issue numbers. Restated in terms of the components: `; on-disk/process durability remains adapter work for issues 010/011.` -> `, not on-disk or cross-process durability, which the persistence adapter modules cover.` Verified: this fixture uses `InMemoryBookkeeper` and an in-memory SoT, so it cannot prove on-disk durability, and the on-disk counterpart exists as `org.mobilenativefoundation.store6.sqldelight.SqlDelightDurableMaintenanceTest.invalidate_markIsObservedByFreshStoreUsingSharedCollaborators` (same test name, real driver) plus `org.mobilenativefoundation.store6.room.RoomStoreSubstitutionConformanceTest`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/KeyEnginePlanningTest.kt:3440` | "the exact R1 row must reach collector delivery before confirmFresh", | FP | Executable string, out of doc-pass charter. The `R1` sits inside the `assertTrue(...)` failure message of `org.mobilenativefoundation.store6.core.internal.KeyEnginePlanningTest.activeExactRowMappedBeforeConfirmFresh_overlayRetireRevealsConfirmedValue`. No change. Recorded as known out-of-charter residue. | + +Task 3b concerns, recorded for the controller: + +1. **The brief's premise for the `TransactionalSourceOfTruth.kt` paragraph deletion is factually wrong in one respect, though the ruling still holds.** The brief states the paragraph "prescribes a decorator protocol nothing in the current tree implements". The tree does contain that implementation: `store6-extension-probe/src/commonMain/kotlin/org/mobilenativefoundation/store6/extensionprobe/CoordinatedTransactionalSourceOfTruth.kt:48`, exercised by `org.mobilenativefoundation.store6.extensionprobe.CoordinatedTransactionalSourceOfTruthTest`. The deletion was still executed as ruled, and remains correct on the rubric for reasons independent of that premise: the paragraph documents an *extension-side* protocol on a *core* interface's published KDoc, it carries the banned `row-7/8` design-table referent, and the protocol is already documented accurately where it is implemented — the probe's own class KDoc (lines 27-45) covers per-key gating, one-transaction write-and-retire, post-commit `apply`/`confirmFresh`, collection restart and authoritative recapture, single retirement signal, and rollback, and states the `confirmFresh` caveat as "does not rely on `confirmFresh` as an observation path". Note that `store6-extension-probe` is an unpublished module, so nothing published now documents this protocol; if the controller wants it published, the probe's KDoc is the accurate source to promote. +2. **The retained `confirmFresh` sentence is now unanchored.** `* \`StoreWriteHandle.confirmFresh\` alone is not an observation mechanism.` was kept per the brief (it is a real contract clause, pinned by `org.mobilenativefoundation.store6.core.internal.KeyEnginePlanningTest.activeExactRowMappedBeforeConfirmFresh_overlayRetireRevealsConfirmedValue`, where `confirmFresh` is followed by `observer.expectNoEvents()`). With the paragraph that motivated it gone, it now reads as a standalone remark about a different type inside `TransactionalSourceOfTruth`'s KDoc. It was promoted to its own paragraph rather than deleted, because deleting it would drop a behavioral guarantee. A Task 7 audit may prefer to move it onto `StoreWriteHandle.confirmFresh` itself, whose current KDoc says only that active streams "may observe one data re-emission". +3. **Unflagged landing-state residue left in place:** `KeyRegistry.kt:23` still reads "preserving the landed double-sweep-under-fence semantics". No sweep pattern matches it and it was not a v2 hit, so it was left unedited rather than expanding scope; flagged as a Task 7/8 candidate. + +--- + +## Task 4 — store6-mutations + +Scope: all baseline hits in `store6-mutations/src`, widened to the full Sweep-v1 + Sweep-v2 union (Detection sweep v1 hard + v1 classify-only + Amendment A1 v2 hard + v2 classify-only), deduplicated by `file:line`. The densest module in the repo. + +Row-set command (run from the repo root, scoped to this module): + +```bash +# v1 hard: the Detection sweep regex verbatim from the top of this file, scoped to the module +grep -rEn '' --include='*.kt' --exclude-dir=build store6-mutations/src | sort > m-v1.txt +grep -rEn 'engine-design|design §|§[0-9]+|\bTEST-[0-9]+\b|\bC-[0-9]{2}\b|\bAC-[0-9]+\b|\bOQ-[0-9]+\b|\brow-7/8\b' \ + --include='*.kt' --exclude-dir=build store6-mutations/src | sort > m-v2.txt +grep -rEn 'ruling|ruled|adopted shape|erratum' --include='*.kt' --exclude-dir=build store6-mutations/src | sort > m-c1.txt +grep -rEn '\b0(0[1-9]|1[0-9]|2[0-9])\b|\bR[0-9]\b|\bT2E\b' --include='*.kt' --exclude-dir=build store6-mutations/src | sort > m-c2.txt +cat m-v1.txt m-v2.txt m-c1.txt m-c2.txt | sort -u | wc -l # 269 +``` + +Measured at the start of Task 4: v1 hard **136**, v2 hard **15**, v1 classify-only **21**, v2 classify-only **137**; deduplicated union **269 rows** across 29 files (11 commonMain, 17 commonTest, 1 jvmTest). The 157 rows the Task 1 baseline recorded for this module are a subset (v1 hard + v1 classify-only only); the other 112 rows are the Sweep-v2 additions required by Amendment A1 and are added below. + +Post-pass state: v1 hard **0**, v2 hard **1** (a string literal in executable code — see `MutationRestartWalkingTest.kt:100`), v1 classify-only **1** (assertion-message string literal), v2 classify-only **4** (three occurrences of the number `1,024`, one test-key string literal). Every survivor has an `FP` row below. + +Class counts: **P1 171 · P2 2 · P3 60 · P4 31 · FP 6 · Unverifiable 0** (270 rows = 269 measured sweep hits + 1 non-sweep P2 added at review). + +#### `store6-mutations` (270 rows: 157 Task 1 baseline + 112 Sweep-v2 additions + 1 non-sweep P2 added at review) + +| file:line | excerpt | class (P1-P4/FP/Unverifiable) | action taken | +| --- | --- | --- | --- | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationBookkeeper.kt:21` | * inaccessible internal default is never substituted (D9). Certified against | P1 | P1: deleted `(D9)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:54` | /** The single in-memory attempt generation every 021 push transmits; merges are 023's (D2). */ | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:64` | /** Stable machine detail for a resolver that returned null during global drain (D14). */ | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:67` | /** Stable machine detail for a resolver whose returned pair mismatched the request (D14). */ | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:70` | /** Stable machine detail for a resolver that threw a non-cancellation failure (D14). */ | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:76` | /** Stable machine detail for a keyed drain whose aliased terminal key failed to resolve (D14). */ | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:119` | // retained here — ordered base capture reads [bookkeeper]; 024's transactional selection | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:125` | // conflicts is stored for 023. Defaults exist only for direct engine construction in | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:142` | // R-0 §1's stable installation identity, in-memory form: one fixed string per engine is the | P1 | P1: deleted `R-0 §1`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:143` | // ruled 021 shape; 022 owns durable client rows. | P4 | P4: `one fixed string per engine is the ruled 021 shape; 022 owns durable client rows.` -> `one fixed string per engine, persisted on the durable client row and stamped into every push, attempt, and failure.` Verified: `MutationClientRecord.clientId` and the `clientId = clientId` stamping sites. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:168` | // In-memory effect snapshots captured before first push (D8/R-0 §7); never executed at 021. | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:173` | // In-memory execution bookkeeping for truthful inspection (D3/R-0 §3). All of it is | P1 | P1: deleted `(D3/R-0 §3)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:174` | // rewritten over durable records at 022/023. | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:206` | // 022/023 rebuild routing over durable records. | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:231` | // R-0 §1's contiguous locally retired prefix, in-memory form, advertised on pushes (D15a). | P1 | P1: deleted `(D15a)`, `R-0 §1`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:250` | /** The advisory lifecycle bus republished by the facade; 023 owns causal emission. */ | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:702` | * One idempotent keyed foreground pass (D12): captures the unprojected confirmed base through | P1 | P1: deleted `(D12)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:704` | * retry or backoff. A ruled pre-ack codec/projection failure parks that head and continues its | P1 | P1: deleted `ruled`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:706` | * resolves the terminal alias identity before calling this (D15a); a mid-pass activation | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:729` | * One idempotent global foreground pass (D12): enumerates durable identities from the | P1 | P1: deleted `(D12)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:731` | * (D14), and continues past identities that fail to resolve after parking exactly one owned | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:900` | * The normalized in-memory drain failure carriers recorded so far (D3/D14/D15a in 021 form): | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:902` | * `StoreError` is retained; 022/023 own the durable failure rows and the parking these | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:926` | * Snapshot rows for every durable identity in durable client-sequence order (D3): the | P1 | P1: deleted `(D3)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:946` | * Durably parked intents only (D3). Always empty at 021: parking is 023's transition over | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:947` | * 022's durable rows, and the walking skeleton never fakes it. The normalized failure | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:970` | /** The terminal identity for [identity] under the active alias edges (D15a). */ | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:1052` | * The suspending-facade resolution door (D14): one attempt, then the sanctioned | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:1071` | * The keyed-drain resolution door (D14): a failed terminal resolution parks one owned durable | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:1265` | // A journalled row can never be PARKED or RETIRED at 021; PENDING is the total | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. Pin for the retained claim (now `MutationEngine.kt:1261-1262`): a parked row never reaches the pending snapshot because `publishDurablePark` calls `journal.retire(identity, entry.mutationId)` at `MutationEngine.kt:2551`, and `MutationJournal.retire` filters the entry out of the identity's snapshot at `MutationJournal.kt:313`. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:1421` | * Alias interaction (D15a): the pass drains the durable-client-sequence prefix that existed | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:1427` | * key's pass; 023 owns the durable park transition that halt previews. | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:1651` | /** Protected 021 path for direct, codec-less engine constructions in module tests. */ | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:1730` | // pass replays the same immutable generation (D2). Only a non-cancellation | P1 | P1: deleted `(D2)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:2741` | // Cross-namespace acknowledgement rejection retains its separately ruled posture. | P4 | P4: `Cross-namespace acknowledgement rejection retains its separately ruled posture.` -> `A cross-namespace rejection deliberately halts without parking.` Verified against the surrounding `recordDurableAckProtocolRejection` branch (retarget/cycle/retry-mismatch park; cross-namespace halts). | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:3695` | * behind it (D12). Ties (direct journal appends in module tests use the default sequence) | P1 | P1: deleted `(D12)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:3770` | * The ordered base-capture loop (Shared invariants; R1-18). A present value accepts the | P1 | P1: deleted `R1-18`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:3815` | // Overlay.apply terminalizes the key's projected streams permanently (008 contract). | P1 | P1: deleted `(008 contract)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:3828` | * Builds the immutable in-memory attempt generation (D2, R-0 §4): stable client identity and | P1 | P1: deleted `(D2, R-0 §4)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:3860` | * durable steps 2 and 4 are 022/023's). | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:3933` | // The codec-less 021 preview retries by retransmitting the same generation. Retain | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:3950` | * unrepresentable (D15a). Issue 022 lands tombstone storage and hydration; the ack/clear and | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:3951` | * activation transitions remain 023/024-owned. | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:3981` | * transactional coordination is 024's (R1-23); restart rehydration is 022's. | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:4023` | /** Advances the in-memory contiguous retired prefix (D15a); gaps hold the high-water. */ | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:4036` | * (D8/R-0 §7). A throwing `stales` function is contained exactly like a throwing projector — | P1 | P1: deleted `(D8/R-0 §7)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:4037` | * ephemeral poison, no transport — and halts this key's pass. 021 never executes effects. | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:4067` | /** The captured (never executed) effect snapshot for a mutation; 022 owns durability. */ | P4 | P4: `022 owns durability` -> `the durable rows are [durableEffectsSnapshot]`. Verified: `durableEffectRows` is the executed set (`resumeDurableEffectsPending`), `effectSnapshots` is the in-memory capture read only by `MutationEffectsTest`. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:4096` | /** A library-owned immutable snapshot of captured metadata fields (D2). */ | P1 | P1: deleted `(D2)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEvents.kt:13` | * Read-only, in-process advisory mutation telemetry (D4). | P1 | P1: deleted `(D4)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEvents.kt:268` | * A non-cancellation checkpoint transport, protocol, or persistence failure (D12). Client-scoped | P1 | P1: deleted `(D12)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEvents.kt:293` | * TD-8 note: a `MutableSharedFlow` configured with [BufferOverflow] is legal advisory plumbing; | P1 | P1: deleted the `TD-8 note:` label and recapitalized; both design-constraint clauses (legal advisory plumbing, Channels/actors banned as protocols) and the whole emission contract survive verbatim. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspection.kt:15` | * The total public mapping of every nonterminal active execution phase (D3). | P1 | P1: deleted `(D3)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspection.kt:30` | /** The broad, append-only classification of a normalized mutation failure (R-0 §6). */ | P1 | P1: deleted `R-0 §6`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspection.kt:45` | * A normalized, restart-safe failure record (D3). | P1 | P1: deleted `(D3)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspection.kt:48` | * contract: [detail] is at most 128 UTF-8 bytes and [message] at most 1,024 UTF-8 bytes, each | FP | FP: matched only by the classify-only `\b0(0[1-9]|1[0-9]|2[0-9])\b` pattern via the number `1,024` (the message byte budget). No change; the number is protected content. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspection.kt:62` | /** Sanitized human-readable diagnostic; at most 1,024 UTF-8 bytes. */ | FP | FP: same `1,024` byte-budget number. No change. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspection.kt:128` | * A truthful snapshot of one nonterminal active intent (D3). | P1 | P1: deleted `(D3)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspection.kt:165` | * A durably parked intent (D3). Dead letters contain only parked entries; parking is legal only | P1 | P1: deleted `(D3)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspection.kt:205` | * An ephemeral projection-failure report carrying the exact local `Throwable` (D3). | P1 | P1: deleted `(D3)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:34` | /** R-0 §7's effect kind, in-memory form; 022 owns the durable stable names. */ | P4 | P4: `R-0 §7's effect kind, in-memory form; 022 owns the durable stable names.` -> `The in-memory invalidation-effect kind; durable names are `storage.MutationEffectKind`.` Verified: `storage/MutationJournalRecords.kt` defines that public enum. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:41` | * One normalized in-memory invalidation-effect target (R-0 §7's immutable snapshot shape). | P1 | P1: deleted `R-0 §7`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:43` | * 021 captures these before first push and never executes them; execution, dispositions, and | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:44` | * durability are 022/023/024's. | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:54` | * records (D8): every key is normalized to its full identity pair; ordering is namespace effects | P1 | P1: deleted `(D8)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:87` | * R-0 §3's execution phase vocabulary, in-memory form; 022 owns the durable stable names. | P4 | P4 + P3: `R-0 §3's execution phase vocabulary` -> `The execution phase vocabulary in its in-memory form; the durable stable names are `storage.MutationExecutionPhase`.` The whole `At 021 ... none of which 021 fakes` paragraph was deleted as stale (all phases now have producers); the total-mapping contract it referenced is documented verbatim on `toPendingStateOrNull` below. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:89` | * At 021 the engine's foreground pass truthfully produces only `UNPREPARED`, `READY`, | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:90` | * `INFLIGHT`, and `ACKED` (plus removal on retirement). `REFRESH_REQUIRED` needs 023's conflict | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:91` | * pipeline, `EFFECTS_PENDING` needs 022/023's effect execution, and `PARKED` needs 023's parking | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:92` | * transaction — none of which 021 fakes. The total public mapping is nevertheless frozen here so | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:93` | * inspection shapes are proven against the ruled vocabulary (D3, R-0 §3). | P1 | P1: deleted `(D3, R-0 §3)`, `ruled`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:127` | * [createdAtEpochMillis] the durable enqueue stamp (R-0 §2); the engine allocates both at | P1 | P1: deleted `R-0 §2`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:185` | * The durable identities that currently hold pending intents, in first-enqueue order (D12): | P1 | P1: deleted `(D12)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:437` | /** The 021-compatible default journal, now implemented by the public in-memory storage seam. */ | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:442` | // Cache-fronted canonical alias routing (D15a). | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:445` | /** Stable machine detail for a canonical target in a different namespace (D15a). */ | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:448` | /** Stable machine detail for a second canonical target claimed for an aliased source (D15a). */ | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:451` | /** Stable machine detail for a canonical target whose chain reaches back to its source (D15a). */ | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:454` | /** Stable machine detail for a generation retry acknowledging a different canonical target (D15a). */ | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:459` | * The lifecycle of one alias edge (D15a): `PENDING` between validated acknowledgement receipt and | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:469` | /** One normalized same-namespace full-pair redirect: source identity to target identity (D15a). */ | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:516` | /** The outcome of validating one acknowledged canonical target at ack receipt (D15a). */ | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:545` | * transactional retirement composition remain later slices. Deferred proofs: 022 | P3 | P3: deleted `tombstone activation orchestration and transactional retirement composition remain later slices. Deferred proofs: 022 ...; 023 ...` and `Tombstone generations and high-water interaction are R1-21's 022/023/024 tests; 021 records no tombstones.` Verified stale: `MutationEngine` inserts, activates, replaces, and hydrates `MutationKeyTombstoneRecord`s, and both named tests exist. Kept the commit-before-publish and restart-hydration contracts plus a plain statement that tombstone state is modeled separately. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:546` | * `MutationJournalContractTest.kt::aliasEdgesAndActivation_roundTripAcrossRestart`; 023 | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:548` | * Tombstone generations and high-water interaction are R1-21's 022/023/024 tests; 021 records | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:558` | // redirect is also a protocol failure. 022 owns the durable receipt row. | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:579` | * persist or publish, an optional pending redirect edge (D15a): | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocol.kt:16` | * (D13). Push, acknowledgement, conflict, attempt, and adoption carriers contain non-null | P1 | P1: deleted `(D13)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocol.kt:48` | * components verbatim (D14). | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocol.kt:84` | * decoders remain until the corresponding rows are safely retired and pruned (D7). | P1 | P1: deleted `(D7)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocol.kt:111` | * The result of a pure registered invalidation function `(key, args) -> StaleSet` (D8). | P1 | P1: deleted `(D8)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocol.kt:129` | * The library-owned capture carrier handed to the optional precondition selector (D2). | P1 | P1: deleted `(D2)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocol.kt:169` | * The immutable, library-built transport request for one attempt generation (D2). | P1 | P1: deleted `(D2)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocol.kt:262` | * Backend coherence obligation for confirmed deletion (D13), certified by returning | P1 | P1: deleted `(D13)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocol.kt:272` | * retention (D15b). | P1 | P1: deleted `(D15b)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocol.kt:285` | * The backend's acknowledgement of one pushed generation (D13). | P1 | P1: deleted `(D13)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocol.kt:289` | * library alone constructs pushes, retirements, identities, inspection rows, and failures (D11). | P1 | P1: deleted `(D11)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocol.kt:303` | * must return the same canonical target or the intent parks as a protocol violation (D15a). | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocol.kt:322` | * Backend coherence obligation (D13): Every fetch begun after an Absent acknowledgement returns | P1 | P1: deleted `(D13)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocol.kt:334` | * The library-built retirement checkpoint request (D15b). | P1 | P1: deleted `(D15b)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocol.kt:351` | * The consumer-built confirmation of a retirement checkpoint (D15b). | P1 | P1: deleted `(D15b)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocol.kt:368` | * persisted server-confirmed prefix. Returns the validated new confirmed prefix. Issue 023 | P4 | P4: `Issue 023 normalizes the thrown failure as `MutationFailureKind.PROTOCOL`.` -> `The engine normalizes ...`. Verified at `MutationEngine.flushRetirementCheckpoint`, which catches `validateRetirementAck` and calls `emitCheckpointFailure(kind = MutationFailureKind.PROTOCOL, detail = "retire-ack-invalid")`. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocol.kt:388` | * Library-side exact-pair resolution validation (D14): the engine calls this on every resolver | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocol.kt:412` | * The explicit outcome of a consumer merge hook after a precondition conflict (D2). | P1 | P1: deleted `(D2)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationSourceOfTruth.kt:24` | * engine; core's inaccessible internal default is never substituted, and Issue 024 can select and | P3 | P3 + P1: deleted `and Issue 024 can select and report its explicit non-transactional fallback because this retained default is visible to it (D9)`; kept the forwarded-exact-instance and never-substituted contract plus the whole contract-kit certification list. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationSourceOfTruth.kt:26` | * (D9). Certified against `SourceOfTruthContractKit`: | P1 | P1: deleted `(D9)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:38` | * and routes every key-taking operation through the canonical alias table (D15a). | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:40` | * PROVISIONAL pending Issue 021: this facade deliberately withholds the raw engine write handle. | P2 | P2 stamp `PROVISIONAL pending Issue 021:` deleted (worked example). Every contract clause survives verbatim: the withheld write handle, `runtime()` returning `null`, re-published [keyEvents] with no `Rekeyed` variant, and the close ordering. Pinned by `org.mobilenativefoundation.store6.mutations.MutationAckPathTest.rawWriteHandleUnreachableThroughFacade` (asserts `assertNull(users.runtime())` and `assertNotNull(bare.runtime())`) and re-asserted inside `org.mobilenativefoundation.store6.mutations.MutationsWalkingSkeletonTest.offlineMutation_projectsOverlay_thenAckLandsConfirmedWithoutRefetch`. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:66` | * Observes retrieval state and values for the terminal canonical identity of [key] (D15a). | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:68` | * Alias liveness contract (D14): before resolving, the stream snapshots the mutation-owned | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:177` | * Returns the value for the terminal canonical identity of [key] (D15a); one resolution | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:179` | * (D14). This read is never projected by the overlay; overlays apply only to [stream]. | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:190` | * Marks the terminal canonical identity of [key] stale (D15a); one resolution attempt, | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:191` | * throwing a `StoreResults.conversionError`-backed [StoreException] on failure (D14). | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:199` | * Destructively removes the value for the terminal canonical identity of [key] (D15a); one | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:201` | * failure (D14). | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:250` | * Runs one idempotent, scheduler-agnostic global foreground pass (D12): every durable | P1 | P1: deleted `(D12)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:252` | * [MutationKeyResolver] with exact-pair validation (D14). An identity that fails to resolve | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:270` | * Aliases are followed as durable identity pairs only (D14): this inspection never | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:283` | * identities, in durable client-sequence order (D3). Retired history never appears. | P1 | P1: deleted `(D3)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:292` | * Returns the durably parked intents (D3). Dead letters contain only `PARKED` entries; | P1 | P1: deleted `(D3)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:293` | * retired history never appears and post-acknowledgement work never parks. At 021 this list | P3 | P3: deleted the stale/speculative sentence `At 021 this list is always empty: parking is produced by Issue 023 over Issue 022's durable rows.` Verified false in this tree: `MutationEngine.publishDurablePark` populates `deadLettersByMutationId` and hydration rehydrates `PARKED` rows. Kept the two verified clauses (`PARKED`-only; retired history never appears; post-acknowledgement work never parks — post-ack failures go through `retryablePostAckPersistence`, which retries rather than parks). Pinned by `org.mobilenativefoundation.store6.mutations.MutationDrainParkingTest`. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:294` | * is always empty: parking is produced by Issue 023 over Issue 022's durable rows. | P3 | Same hunk as MutationStore.kt:293. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:308` | * Read-only, in-process advisory lifecycle events (D4): replay `0`, extra buffer capacity | P1 | P1: deleted `(D4)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:310` | * or settlement protocol; durable truth remains inspection. Issue 023 owns causal emission. | P3 | P3: deleted `Issue 023 owns causal emission.`; kept the verified advisory-bus contract (replay `0`, extra capacity `64`, `DROP_OLDEST`, non-blocking) and the never-a-protocol clause. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:316` | /** The exact Bookkeeper the engine retained (D9); test/022/024 verification door. */ | P4 | P4 + P1: `(D9); test/022/024 verification door` -> `; the verification door for retention tests`. Verified against `MutationStoreBuilderTest.explicitBookkeeper_isSameInstanceForStoreAndMutationEngine` / `defaultBookkeeper_isSameInstanceForStoreAndMutationEngine`, which read this accessor. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:320` | /** The exact SourceOfTruth the engine retained (D9); test/022/024 verification door. */ | P4 | P4 + P1: same rewrite as MutationStore.kt:316. Verified against `MutationStoreBuilderTest.explicitSourceOfTruth_isSameInstanceForStoreAndMutationEngine` / `defaultSourceOfTruth_isSameInstanceForStoreAndMutationEngine`. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:335` | // (D14): the waiter observes the closed signal, cancels promptly, and its `first` | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:362` | * The ruled entry point (D1): restart behavior is compile-time required — the registry, server, | P1 | P1: deleted `(D1)`, `ruled`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:366` | * delegated Store AND retained by the engine, so Issue 024's transactional decorator can select | P3 | P3: deleted `so Issue 024's transactional decorator can select and report its path`; kept the verified contract that the retained selections are installed in the delegated Store AND retained by the engine, restated as `so the selection is never an inaccessible core default`. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:367` | * and report its path instead of silently discovering an inaccessible core default (D9). The | P1 | P1: deleted `(D9)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt:26` | * layer and is installed by the factory (D9, R1-13). The required mutation inputs — registry, | P1 | P1: deleted `(D9, R1-13)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt:28` | * builder doors (D1). | P1 | P1: deleted `(D1)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt:34` | * silently substituted (D9). Issue 024 selects its transactional decorator — or reports its | P3 | P3 (worked example): deleted `Issue 024 selects its transactional decorator — or reports its explicit non-transactional fallback — against the retained persistence instance.`; kept the never-silently-substituted contract. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt:94` | * sides (D9). Custom implementations should be validated with the source-of-truth contract | P1 | P1: deleted `(D9)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt:95` | * kit. Issue 024 selects transactional adoption — or reports its explicit non-transactional | P3 | P3: deleted the same Issue 024 sentence from the `persistence` door; kept the retained-instance contract and the contract-kit pointer. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt:151` | * The surface and its registration validation land at Issue 021 (D2); precondition selection | P3 | P3: deleted `The surface and its registration validation land at Issue 021 (D2); precondition selection and merge execution are owned by Issue 023's fixed conflict pipeline (R1-19), so nothing registered here executes at 021.` Verified stale: `MutationEngine.selectPreconditionMeta` invokes `conflicts?.precondition` and `resolveDurableConflict` invokes `conflicts?.merge`. Kept the server-wins terminal and last-registration-wins contracts. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt:152` | * and merge execution are owned by Issue 023's fixed conflict pipeline (R1-19), so nothing | P3 | Same hunk as MutationStoreBuilder.kt:151. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt:153` | * registered here executes at 021. Without a registered merge, server-wins is the | P3 | Same hunk as MutationStoreBuilder.kt:151. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt:168` | * mutations-owned [InMemoryMutationJournalStorage], preserving the 021 in-memory behavior. | P3 | P3: `preserving the 021 in-memory behavior` -> `so the journal stays in memory`; the restart-hydration sentence is unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt:206` | * Registers the conflict policy surface ruled by D2. | P1 | P1: deleted `ruled`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt:208` | * Registration-time validation only at Issue 021: each policy registers at most once per block, | P3 | P3: `Registration-time validation only at Issue 021` -> `Registration is validated as it happens`; deleted `nothing registered here is executed before Issue 023's pipeline lands` (stale). Kept the at-most-once and no-terminal-setter contracts. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt:209` | * and nothing registered here is executed before Issue 023's pipeline lands. There is | P3 | Same hunk as MutationStoreBuilder.kt:208. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt:229` | * Store6 selects the candidate's captured metadata. Execution is owned by Issue 023 and runs | P3 | P3 + verified restatement: `Execution is owned by Issue 023 and runs once per newly prepared semantic generation, never on a transport retry (D2).` -> `The selector runs once per newly prepared semantic generation, never on a transport retry.` Verified: `selectPreconditionMeta` is called only from the durable prepare paths; a transport retry retransmits the same generation. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt:230` | * once per newly prepared semantic generation, never on a transport retry (D2). | P1 | P1: deleted `(D2)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt:248` | * repeat policy are owned by Issue 023 (D2, R1-19). | P4 | P4: `Execution, its fixed failure transitions, and its bounded repeat policy are owned by Issue 023 (D2, R1-19).` -> a restatement on the `merge` door: "Retrying is bounded: on the third consecutive conflict receipt carrying identical server metadata, the intent parks with a normalized `CONFLICT` failure instead of preparing another generation. A merge that throws parks the intent as well." Verified at two sites: `MutationEngine.kt:62` (`CONFLICT_UNCHANGED_BOUND: Int = 3`) and `MutationEngine.kt:2226`, where a trailing run of attempts whose conflict receipt has non-null meta and equal `conflictEtag`/`conflictWrittenAt` reaches the bound and commits `StoredExecutionPhase.PARKED` with a `MutationFailureKind.CONFLICT` failure (detail `conflict-unchanged-bound`); the merge-throws arm parks with detail `merge-failed`. Pinned by `org.mobilenativefoundation.store6.mutations.MutationConflictTest.unchangedRepeatedConflictEventuallyParks`. (Originally classed P3 and deleted under the P4 fallback; reclassified at review once the bound was located.) | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt:275` | * The validated conflict policy retained for Issue 023's pipeline. | P3 | P3: `The validated conflict policy retained for Issue 023's pipeline.` -> `The validated conflict policy retained for the engine's conflict pipeline.` | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt:277` | * Stored, never executed, at Issue 021. | P3 | P3: deleted `Stored, never executed, at Issue 021.` — verified stale (the policy is executed by the engine). | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt:294` | * the delegated core Store and the mutation engine (D9). [applyCoreConfiguration] replays every | P1 | P1: deleted `(D9)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutatorRegistry.kt:10` | /** The fixed args-codec version used by every `delete` registration (D7). */ | P1 | P1: deleted `(D7)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutatorRegistry.kt:14` | * The one deliberate args-codec specialization (D7): Store6 owns the `delete` codec at fixed | P1 | P1: deleted `(D7)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutatorRegistry.kt:17` | * so Issue 022 can normalize the throw. | P4 | P4: `so Issue 022 can normalize the throw.` -> `so the engine normalizes the throw into a `CODEC` failure record.` Verified: `MutationEngine.classifyHydrationCodecFailure` builds a `MutationFailureKind.CODEC` normalized failure from a decode throw. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutatorRegistry.kt:38` | * The three type parameters keep key, value, and args compile-time bound at Kotlin call sites; / * R-2a records the experimental-tier waiver and requires a revisit at the first graduation / * review. | P2 | P2: deleted the graduation-review waiver `R-2a records the experimental-tier waiver and requires a revisit at the first graduation review` from published KDoc (rollout/approval state plus an internal referent). The compile-time-binding contract sentence is kept, with the trailing `;` closed to a `.`. The declaration's own `@ExperimentalStoreApi` annotation and STABILITY.md already carry the public stability contract. **Not matched by any sweep pattern** (`R-2a` fails `\bR[0-9]\b`); recorded here for completeness after review, so it is not counted in the 269 measured sweep hits. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutatorRegistry.kt:80` | /** Projects [base] through the registered mutator; `null` means decline only (D13). */ | P1 | P1: deleted `(D13)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutatorRegistry.kt:119` | * `null` means exactly "decline this intent" (D13); a declined head remains pending and | P1 | P1: deleted `(D13)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutatorRegistry.kt:124` | * [stales] is the pure declarative invalidation function (D8): equal inputs must produce | P1 | P1: deleted `(D8)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutatorRegistry.kt:184` | * confirmed base is ignored (D1). | P1 | P1: deleted `(D1)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutatorRegistry.kt:201` | * codec whose encoding is exactly zero bytes (D7). | P1 | P1: deleted `(D7)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/storage/MutationJournalStorage.kt:104` | * Applies a ruled execution-state transition. | P1 | P1: deleted `ruled`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAckPathTest.kt:228` | // R1-05: the sealed Present variant adopts through apply -> confirmFresh, then retires; the | P1 | P1: deleted the `R1-05:` label only. **Tested-invariant sentence — the deliberate adopt-then-retire ordering on the non-transactional ack path.** Meaning preserved verbatim; pinned by `org.mobilenativefoundation.store6.mutations.MutationAckPathTest.presentAck_appliesConfirmsFreshThenRetires` (asserts `listOf("apply", "confirmFresh", "retire")`). | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAckPathTest.kt:289` | // R1-05: the sealed Absent variant adopts through the bound clear door, then retires; the | P1 | P1: deleted the `R1-05:` label only. **Tested-invariant sentence — adopt-then-retire on the Absent arm.** Pinned by `org.mobilenativefoundation.store6.mutations.MutationAckPathTest.absentAck_clearsThenRetires_andHasNoCanonicalKey`. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAckPathTest.kt:457` | // R1-18: present capture reads bookkeeping status BEFORE the LocalOnly value, and the | P1 | P1: deleted `R1-18`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAckPathTest.kt:503` | // R1-18: absence is accepted only from the exact loop status -> LocalOnly Missing -> status | P1 | P1: deleted `R1-18`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAckPathTest.kt:574` | // R1-18: a FetcherResult.Deleted window — the destructive clear that forgets freshness — | P1 | P1: deleted `R1-18`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAckPathTest.kt:585` | // R1-18: a Store.clear(key) window shares the loop, not only the facade interlock. | P1 | P1: deleted `R1-18`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAckPathTest.kt:891` | // DeleteAndCreatePending): under the ruled D13, delete is drainable — a projected Absent | P1 | P1: deleted `ruled`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAckPathTest.kt:1328` | // 017 residual-deadline repair: Turbine's 3s default nested inside the 25s shadow; raise the | P1 | P1: deleted `017 `; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAckPathTest.kt:1329` | // Turbine deadline above the shadow so runTest provides the only effective timeout (D0, PR #15). | P1 | P1: deleted `(D0, PR #15)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:44` | * R1-20/R1-24/R1-09's 021 slice: the same-process canonical alias facade (D15a) and the D14 | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:49` | * The durable model is deliberately absent (022–024). Deferred proofs recorded, not faked: | P4 | P4: `The durable model is deliberately absent (022–024). Deferred proofs recorded, not faked:` -> `The durable model is deliberately out of scope here and is proven elsewhere:` with the issue prefixes stripped from each bullet; every referenced test file exists in this tree. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:50` | * - 022 `MutationJournalContractTest.kt::aliasEdgesAndActivation_roundTripAcrossRestart` | P4 | P4: issue prefix `022` stripped; the bullet now names `MutationJournalContractTest.kt::aliasEdgesAndActivation_roundTripAcrossRestart` alone. Verified: that file and test exist. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:51` | * - 023 `MutationAckOrchestrationTest.kt::ackAliasActivationRebasesQueuedSourceAndTargetSiblings` | P4 | P4: issue prefix `023` stripped; `MutationAckOrchestrationTest.kt::ackAliasActivationRebasesQueuedSourceAndTargetSiblings` exists in this tree. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:52` | * - 023 `MutationConflictTest.kt::serverWinsCancellationAfterCommit_stillPublishesOverlayRevision` | P4 | P4: issue prefix `023` stripped; `MutationConflictTest.kt::serverWinsCancellationAfterCommit_stillPublishesOverlayRevision` exists in this tree. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:53` | * - 023 `MutationDrainParkingTest.kt::parkingCancellationAfterCommit_stillPublishesOverlayRevisionAndRebasesSuffix` | P4 | P4: issue prefix `023` stripped; `MutationDrainParkingTest.kt::parkingCancellationAfterCommit_stillPublishesOverlayRevisionAndRebasesSuffix` exists in this tree. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:54` | * - 023 parks the alias-protocol violations that halt with a normalized `PROTOCOL` carrier here. | P4 | P4: `023 parks the alias-protocol violations …` -> `MutationDrainParkingTest also covers the durable parks for the alias-protocol violations that halt with a normalized PROTOCOL carrier here.` | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:69` | // queued siblings from source and target merge by durable client sequence (D15a). | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:115` | // definition, not stale (ruling: pending UI keys on origin == OVERLAY). | P1 | P1: deleted `ruling`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:123` | // to delegate.stream(canonical) (D15a): the confirmed canonical frame arrives | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:224` | // durable ACKED-never-repushed rule is 022/023's; this preview replays the generation.) | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:793` | // source key, no completion (D14). | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:857` | // the thrown cause in the immediate public exception only (D14). | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:866` | // records the normalized IDENTITY carrier and returns normally (023 converts these | P4 | P4: `(023 converts these halts into durable parks)` -> `The durable engine converts such a halt into a park.` Verified: `MutationEngine.parkDurableAckProtocolFailure` / `parkDurablePreAck`. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:875` | // Resolver null has no cause (D14). | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:911` | // mutate resolves BEFORE the append: failure creates no intent anywhere (D14). | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:951` | // reconstructed, so a dead resolver cannot fail this inspection (D14). | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:1335` | * R1-09's alias-facing ack-variant rule, standalone: a retry of one generation idempotency | P1 | P1: deleted `R1-09`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:1337` | * (D15a). The staging mirrors the retry-mismatch arm of | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:1513` | * canonical id (D15a); every other key acknowledges with an unchanged identity. | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:1526` | /** The ruled public entry point, used where no engine door is needed. */ | P1 | P1: deleted `ruled`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:1810` | // 017 residual-deadline repair: Turbine's 3s default nested inside the 25s shadow; raise the | P1 | P1: deleted `017 `; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:1811` | // Turbine deadline above the shadow so runTest provides the only effective timeout (D0, PR #15). | P1 | P1: deleted `(D0, PR #15)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationBookkeeperContractTest.kt:9` | * R1-11: certifies the mutations-owned default [MutationBookkeeper] against the read-only | P1 | P1: deleted `R1-11`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationBookkeeperContractTest.kt:13` | * Every inherited kit member is a binding R1-11 test contract and runs on every compiled target: | P1 | P1: deleted `R1-11`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationDrainTest.kt:32` | * R1-17's 021 slice: `drain(key)` and `drain()` are idempotent, scheduler-agnostic foreground | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationDrainTest.kt:33` | * passes (D12) and the resolver — not any live key map — is global drain's correctness path | P1 | P1: deleted `(D12)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationDrainTest.kt:34` | * (D14). Restart enumeration is 022's `MutationJournalContractTest`; parked-identity | P4 | P4: the pointer "Restart enumeration is 022's MutationJournalContractTest" became "Restart enumeration is covered by `MutationJournalContractTest`" — the issue prefix is gone and the test name is the whole pointer. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationDrainTest.kt:35` | * continuation, retryable post-ack continuation, and the one-attempt-per-phase rule are 023's. | P4 | P4: `… the one-attempt-per-phase rule are 023's.` -> named the real suites `MutationDrainParkingTest` and `MutationDrainResumabilityMatrixTest`; both exist in this tree. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationDrainTest.kt:257` | // `update` over a stably absent base declines (D13): the head stays PENDING and blocks | P1 | P1: deleted `(D13)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationEffectsTest.kt:140` | // no effect executed (021 has no execution machinery — 022/023 own it). | P4 | P4: `021 has no execution machinery — 022/023 own it` -> `a codec-less engine has no durable effect rows to execute`. Verified: effect execution reads `durableEffectRows` in `resumeDurableEffectsPending`, which a codec-less engine never populates. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationEventSurfaceTest.kt:23` | // R1-22: the non-generic sealed algebra exposes the exact ruled stable fields for both the | P1 | P1: deleted `R1-22`, `ruled`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationEventSurfaceTest.kt:165` | // Sealed exhaustiveness over the event root needs exactly the three ruled branches. | P1 | P1: deleted `ruled`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationEventSurfaceTest.kt:178` | // R1-22: checkpoint events are client-scoped and never fabricate a mutation identity. | P1 | P1: deleted `R1-22`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationEventSurfaceTest.kt:209` | // R1-22: the facade property is a read-only advisory SharedFlow backed by a non-blocking | P1 | P1: deleted `R1-22`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspectionTest.kt:25` | * R1-14/R1-15's 021 slices: truthful pending/pendingWrites/deadLetters snapshots (D3) and the | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspectionTest.kt:26` | * normalized failure carrier's sanitization contract. 021 exercises inspection shapes only and | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspectionTest.kt:27` | * never fakes the durable scheduler: `REFRESHING` and `APPLYING_EFFECTS` have no 021 producer | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspectionTest.kt:28` | * (023's conflict pipeline and 022/023's effect execution own them) and are proven through the | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspectionTest.kt:30` | * Restart hydration is 022's; parking, declined/parked scheduling, and dead-letter production | P4 | P4: "Restart hydration is 022's" became "Restart hydration is covered by `MutationJournalContractTest`". | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspectionTest.kt:31` | * are 023's `MutationDrainParkingTest`. | P4 | P4: "are 023's MutationDrainParkingTest" became "by `MutationDrainParkingTest`" — issue prefix removed, test name kept. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspectionTest.kt:36` | // The ruled total mapping (D3, R-0 §3): every nonterminal active phase maps to exactly | P1 | P1: deleted `(D3, R-0 §3)`, `ruled`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspectionTest.kt:52` | // Live: the phases the 021 foreground pass truthfully produces are visible through | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspectionTest.kt:141` | // All identities, durable client-sequence order, real enqueue stamps (D3). | P1 | P1: deleted `(D3)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspectionTest.kt:199` | val key = MutationsTestKey("never-parked-at-021") | FP | FP / out-of-charter: the hit is inside the string literal `MutationsTestKey("never-parked-at-021")` — executable content, not a comment. Left unedited per the protected-content rule. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspectionTest.kt:205` | // Dead letters contain only durably PARKED executions (D3). 021 records the normalized | P4 | P4: `021 records the normalized in-memory carrier but never parks — parking is 023's transition over 022's rows` -> `A codec-less engine records the normalized in-memory carrier but never parks`. Verified: this test constructs `MutationEngine` without a `valueCodec`, so `durableJournal` is null and the legacy in-memory path runs; the assertion `assertEquals(emptyList(), engine.deadLetters())` is unchanged and still passes. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspectionTest.kt:206` | // in-memory carrier but never parks — parking is 023's transition over 022's rows — so | P4 | Same hunk as MutationInspectionTest.kt:205. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspectionTest.kt:266` | // The message budget is 1,024 bytes with the same code-point rule. | FP | FP: same `1,024` byte-budget number in a comment. No change. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspectionTest.kt:353` | // Poison is the ephemeral exact-Throwable flow (D3); it is not a drain failure carrier, | P1 | P1: deleted `(D3)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournalContractTest.kt:905` | // Adoption already committed its ruled ACKED -> EFFECTS_PENDING boundary; the injected | P1 | P1: deleted `ruled`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationKeyResolverTest.kt:23` | * R1-02's 021 slice: the required resolver is global drain's correctness path (D14). Every test | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationKeyResolverTest.kt:26` | * unresolved pre-ack identity is 023's `MutationDrainParkingTest`; restart hydration is 022's. | P4 | P4: `is 023's `MutationDrainParkingTest`; restart hydration is 022's.` -> `is covered by `MutationDrainParkingTest`, restart hydration by `MutationJournalContractTest`.` | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocolTest.kt:30` | // R1-01 (021 slice): the server signature receives the complete library-built carrier for a | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocolTest.kt:81` | // R1-03. | P1 | P1: deleted `R1-03`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocolTest.kt:103` | // R1-03. | P1 | P1: deleted `R1-03`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocolTest.kt:119` | // R1-03. | P1 | P1: deleted `R1-03`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocolTest.kt:142` | // R1-05: presence, never nullable V, crosses push/candidate/ack carriers. | P1 | P1: deleted `R1-05`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocolTest.kt:174` | // R1-08. | P1 | P1: deleted `R1-08`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocolTest.kt:201` | // R1-08: identity is the sole server-authoritative address; the resolved key is | P1 | P1: deleted `R1-08`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocolTest.kt:236` | // R1-08 (021 slice): the exact-pair validation the engine runs before transport; D14 fixes | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocolTest.kt:255` | // R1-08, strengthened to the engine path at T4.5 (Surface NOTES §3.11): the ENGINE rebuilds | P1 | P1: deleted `R1-08`, `T4`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocolTest.kt:259` | // R1-08's 023 proof. | P4 | P4: `Durable INFLIGHT exact-replay is R1-08's 023 proof.` -> `… is proven by `MutationDrainResumabilityMatrixTest`.` Verified: that suite exists and covers INFLIGHT resumability. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocolTest.kt:327` | // R1-08 adjunct (T4.5): the acknowledged authoritative value is rebuilt through the codec's | P1 | P1: deleted `R1-08`, `T4`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocolTest.kt:399` | // R1-09: the sealed variants make a canonical target on confirmed absence unrepresentable. | P1 | P1: deleted `R1-09`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocolTest.kt:428` | // R1-10: library-side monotonic validation keeps the consumer-built carrier plain. | P1 | P1: deleted `R1-10`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocolTest.kt:470` | // R1-19 (021 slice): the capture candidate carries immutable captured metadata and nothing | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocolTest.kt:510` | // R1-18 adjunct (carrier-level; the ordered-capture behavioral tests are T4.5's). | P1 | P1: deleted `R1-18`, `T4`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocolTest.kt:525` | // T4.1 bullet: stable public enums expose the exact ruled value sets. | P1 | P1: deleted `T4`, `ruled`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationRestartWalkingTest.kt:100` | fetcher { error("The AC-4 LocalOnly scenario must not fetch") } | FP | FP / out-of-charter, and the ONE remaining Sweep-v2 hard-gate hit in this module: `fetcher { error("The AC-4 LocalOnly scenario must not fetch") }`. The referent lives inside a string literal in executable code, so editing it would change an executable token; per the verification rule the edit was stopped and the boundary is reported instead (same class as Amendment A1's recorded `error(...)`-message residue). | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationSourceOfTruthContractTest.kt:11` | * R1-12: certifies the mutations-owned default [MutationSourceOfTruth] against the read-only | P1 | P1: deleted `R1-12`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationSourceOfTruthContractTest.kt:15` | * Every inherited kit member is a binding R1-12 test contract and runs on every compiled target: | P1 | P1: deleted `R1-12`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilderTest.kt:36` | * R1-01/R1-11/R1-12/R1-13/R1-24 builder and factory forwarding contract (T4.3). | P1 | P1: deleted `R1-01`, `R1-11`, `R1-12`, `R1-13`, `R1-24`, `T4`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilderTest.kt:38` | * The ruled `MutationStoreBuilder` mirrors core's optional doors, exposes no overlay door, and | P1 | P1: deleted `ruled`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilderTest.kt:40` | * Store and the mutation engine (D9). ABI absence of an overlay setter, runtime, and write-handle | P1 | P1: deleted `(D9)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilderTest.kt:43` | * `:store6-mutations:apiCheck` (R1-13); this common suite proves the doors' forwarding behavior. | P1 | P1: deleted `R1-13`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilderTest.kt:49` | // factory parameters, never builder doors. The named-argument call pins the ruled | P1 | P1: deleted `ruled`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilderTest.kt:88` | // Engine side: the mutation engine retained the caller's exact instance (D9). | P1 | P1: deleted `(D9)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilderTest.kt:129` | // Engine side: the mutation engine retained the caller's exact instance (D9/024). | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilderTest.kt:203` | // ...and the same exact instances retained for the engine (D9). | P1 | P1: deleted `(D9)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilderTest.kt:206` | // conflicts door (D2): surface and registration validation land at 021; nothing | P4 | P4: `surface and registration validation land at 021; nothing registered here executes before 023's pipeline` -> `nothing registered here runs for a merely pending mutation. The selector runs when a drain prepares a generation, the merge only on a conflict.` Verified against `selectPreconditionMeta` (prepare paths only) and the conflict path; the test's two `assertEquals(0, …)` assertions are unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilderTest.kt:207` | // registered here executes before 023's pipeline, even with a pending mutation. | P4 | Same hunk as MutationStoreBuilderTest.kt:206. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilderTest.kt:287` | // R1-24: the facade re-exposes the delegate runtime's advisory flow unchanged. The | P1 | P1: deleted `R1-24`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilderTest.kt:311` | // The builder has no overlay door (compile-time absence; ABI proof is R1-13's dump | P4 | P4: `ABI proof is R1-13's dump test` -> `the ABI proof is the jvmTest dump test`, which the same file's class KDoc names in full. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilderTest.kt:348` | /** Minimal ruled two-method server: acknowledges this client's value and confirms retirement. */ | P1 | P1: deleted `ruled`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationsTestFixtures.kt:22` | * (D15a): a canonical target in another namespace must be constructible so cross-namespace | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationsTestFixtures.kt:33` | /** Exact-pair resolver for the module's single-namespace test key (D14). */ | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationsTestFixtures.kt:92` | * redirects a provisional identity (D15a); [absentPushBehavior] scripts Absent-projection pushes | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationsWalkingSkeletonTest.kt:245` | // 017 residual-deadline repair: Turbine's 3s default nested inside the 25s shadow; raise the | P1 | P1: deleted `017 `; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationsWalkingSkeletonTest.kt:246` | // Turbine deadline above the shadow so runTest provides the only effective timeout (D0, PR #15). | P1 | P1: deleted `(D0, PR #15)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationsWiringSpikeTest.kt:21` | * Successor to `lastOverlayRegistrationWins` (T4.3's ruled compile-time posture). | P1 | P1: deleted `T4`, `ruled`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationsWiringSpikeTest.kt:26` | * displacement is superseded by compile-time absence. R1-13's dump audit proves the | P4 | P4: "R1-13's dump audit proves the ABI-level absence" became "`MutationApiSurfaceTest` proves the ABI-level absence". Verified: `MutationApiSurfaceTest.apiDumpContainsNoOverlaySetterRuntimeOrWriteHandleExposure` asserts the dump contains no `overlay(`, `StoreRuntime`, or `StoreWriteHandle`. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutatorSugarTest.kt:54` | // R1-06. | P1 | P1: deleted `R1-06`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutatorSugarTest.kt:77` | // R1-06. | P1 | P1: deleted `R1-06`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutatorSugarTest.kt:93` | // Null is the decline signal (D13): the declined head never becomes an attempt and never | P1 | P1: deleted `(D13)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutatorSugarTest.kt:98` | // R1-06. | P1 | P1: deleted `R1-06`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutatorSugarTest.kt:121` | // R1-06. | P1 | P1: deleted `R1-06`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutatorSugarTest.kt:146` | // R1-06. | P1 | P1: deleted `R1-06`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutatorSugarTest.kt:179` | // R1-04. | P1 | P1: deleted `R1-04`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutatorSugarTest.kt:213` | // R1-04. | P1 | P1: deleted `R1-04`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutatorSugarTest.kt:230` | // Any other durable pair is a codec violation for Issue 022 to normalize as CODEC. | P4 | P4: `a codec violation for Issue 022 to normalize as CODEC` -> `a codec violation the engine normalizes as CODEC`. Verified: `MutationEngine.classifyHydrationCodecFailure`. | +| `store6-mutations/src/jvmTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationApiSurfaceTest.kt:9` | * R1-13: the committed KLib declaration exposes the ruled `MutationJournalStorage` seam, but no | P1 | P1: deleted `R1-13`, `ruled`; the sentence is otherwise unchanged. | +| `store6-mutations/src/jvmTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationApiSurfaceTest.kt:29` | "Committed KLib dump is missing the ruled MutationJournalStorage seam.", | FP | FP / out-of-charter: `"Committed KLib dump is missing the ruled MutationJournalStorage seam."` is an assertion-message string literal (executable content). Left unedited; classify-only pattern, so it may remain. | +| `store6-mutations/src/jvmTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationApiSurfaceTest.kt:70` | "Committed KLib dump differs from the da72d908 T0.3 baseline: $dumpSha256", | FP | Executable string literal (API-surface baseline pin), out of doc-pass charter; investigated at Task 4, ratified at Task 8. The referent (`da72d908` git-commit prefix, `T0.3` baseline label) sits inside an `assertTrue(...)` failure-message string in executable test code, git-blamed to commit `da72d9088187180124bc71aae94e0b0b336cf905` (pre-dating this branch's base `801b8e8`). Same class as the `:29` row above and the two `AC-4` `error(...)`-message FPs recorded for this module. Matched only by Task 8's extended referent-family check (`\bT[0-9]\.[0-9]\b`); no earlier sweep (v1, v2, or the ruling pattern) sees it. Left unedited. | + +Notes carried out of this pass: + +1. **`MutationSourceOfTruth.kt:41`** was resolved by Task 2 (`P3`); its row is retained in the Task 2 table and is not duplicated here (the excerpt no longer matches any sweep). +2. **Stale-not-just-speculative claims.** Several P3 sentences were not merely about future work — they were false in this tree, because the drain pipeline, journal storage, conflict pipeline, tombstones, and durable parking have all landed. Each was verified against code before deletion: `deadLetters()` "always empty" (`MutationEngine.publishDurablePark`), conflict policy "stored, never executed" (`selectPreconditionMeta`, `resolveDurableConflict`), effects "never executed" (`resumeDurableEffectsPending`), tombstones "not yet recorded" (`insertTombstone` / `publishDurableTombstoneReplacements`), and the alias router's "deferred proofs" list (all four named test files exist). +3. **`MutationStoreBuilder.kt:248` was corrected at review.** The first pass deleted "its bounded repeat policy" under the P4 fallback after failing to find a bound. The bound does exist: `CONFLICT_UNCHANGED_BOUND = 3` (`MutationEngine.kt:62`), applied at `MutationEngine.kt:2226`. The contract is now restated on the public `merge` door and the row is reclassified P4. No row in this module ended on the delete-the-rationale fallback. +4. **Referent families outside both sweeps, cleaned opportunistically inside already-flagged files** (same anti-pattern, adjacent to edited text): `R-0 §n` / `R-0 rule n`, `Q-1`/`Q-2`/`Q-4`/`Q-5`, `T3`/`T4.1`–`T4.5`, `Row 11`/`Row 18`/`rows 16-17`, `R-2a`, `(008 contract)`, `T2.4`, and multi-tag parentheticals like `(D12, D15b)` that the single-tag regex `\(D[0-9]+[a-z]?\)` does not match. No unflagged file was opened for style-only edits. +5. **Protected-content boundary (reported, not edited):** `MutationRestartWalkingTest.kt:100` keeps `error("The AC-4 LocalOnly scenario must not fetch")`. It is the module's only remaining Sweep-v2 hard-gate hit; the referent is inside a string literal, so removing it would change an executable token. Same class as Amendment A1's recorded `error(...)`-message residue. A one-token fix is available if the controller rules string literals in scope. + +--- + +## Task 5 — Adapter modules (store6-room, store6-sqldelight, store6-compose, store6-mutations-sqldelight) + +Scope: all baseline hits in each of the four adapter modules (full per-module lists; Task 2 above resolves the `signs off` stamp subset in `store6-room` and `store6-sqldelight` first), widened per Amendment A1 to the Sweep-v1 + Sweep-v2 union over each module's `src` tree. + +Sweep-v2 enumeration (run fresh for this task, scoped to the four module trees) surfaced **10 additional rows** not in the Task 1 baseline: 4 in `store6-room` (three "017 residual-deadline repair" companion lines to already-baselined Turbine-deadline lines, plus one new `AC-2-lite` test-doc tag), 0 in `store6-sqldelight`, 5 in `store6-compose` (`(OQ-3)`, two `Issue-017`/`issue-017` convention comments, and the `issue-007 bounded registry` phrase), 1 in `store6-mutations-sqldelight` (`AC-4` inside an `error(...)` string literal — protected, FP). These are added to the per-module tables below alongside the 23 baseline rows this task resolves (Task 2 already closed the 8 `signs off`/`Matt`/`issue 007` P2 stamp rows also listed here for module completeness). + +Post-pass state (both sweeps, all four module trees): v1 hard **0**, v1 classify-only **0**, v2 hard **1** (the `AC-4` string literal noted above), v2 classify-only **0**. + +Class counts across the 33 rows this task classified: **P1 29 · P3 1 · P4 2 · FP 1**. (The 8 P2 rows below were classified and resolved by Task 2, not this task.) + +#### `store6-room` (27 rows: 23 baseline + 4 Sweep-v2 additions) + +| file:line | excerpt | class (P1-P4/FP/Unverifiable) | action taken | +| --- | --- | --- | --- | +| `store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/RoomBookkeeper.kt:22` | * Durable Room [Bookkeeper] backed by the adapter-owned TD-6 sidecar. | P1 | Deleted `TD-6 `; sentence otherwise unchanged. | +| `store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/RoomBookkeeper.kt:41` | * This seam remains FREEZE CANDIDATE pending Matt signature. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/RoomSourceOfTruth.kt:206` | * When [withTransaction] wraps nested writes for the future TD-11 mutations decorator, nested | P3 | Deleted `for the future TD-11 mutations decorator`; the remaining sentence describes `withTransaction`'s current nested-write behavior (enlist in the outer transaction, invisible until commit), verified against `RoomTransactionalSourceOfTruthTest.nestedWrite_outerRollback_neverPublishesEcho` and `nestedWrite_outerCommit_publishesEchoOnlyAfterCommit`, which pin exactly that contract generically (no mutations-decorator dependency). | +| `store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/RoomSourceOfTruth.kt:212` | * Freeze candidate: issue 007 has landed; the seam freezes only after Matt signs the prepared | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/RoomSourceOfTruth.kt:213` | * sign-off package. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/Store6BookkeeperDao.kt:8` | /** Room primitives for the adapter-owned TD-6 bookkeeping sidecar. */ | P1 | Deleted `TD-6 `; sentence otherwise unchanged. | +| `store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/Store6BookkeepingEntity.kt:14` | * This surface is a seam freeze candidate pending Matt's signature. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/Store6BookkeepingEntity.kt:8` | * Adapter-owned TD-6 bookkeeping sidecar. | P1 | Deleted `TD-6 `; sentence otherwise unchanged. | +| `store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/Store6RoomSchema.kt:8` | * Migration SQL for the adapter-owned TD-6 sidecar. | P1 | Deleted `TD-6 `; sentence otherwise unchanged. | +| `store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/Store6WatermarkEntity.kt:9` | * Adapter-owned TD-6 watermark row. | P1 | Deleted `TD-6 `; sentence otherwise unchanged. | +| `store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomSourceOfTruthReaderSemanticsTest.kt:648` (Sweep v2) | // 017 residual-deadline repair: Turbine's 3s default nested inside the 25s shadow; raise the | P1 | Companion line to :649 below; both edited as one two-line comment. Deleted the `017 residual-deadline repair: ` prefix. | +| `store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomSourceOfTruthReaderSemanticsTest.kt:649` | // Turbine deadline above the shadow so runTest provides the only effective timeout (D0, PR #15). | P1 | Deleted ` (D0, PR #15)`. Combined with :648: two-line comment now reads "Turbine's 3s default nested inside the 25s shadow; raise the Turbine deadline above the shadow so runTest provides the only effective timeout." — matches the wording already landed for the same rationale in `store6-core` conformance tests (e.g. `SourceOfTruthConformanceTest.kt`). | +| `store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomStoreSubstitutionConformanceTest.kt:111` | /** FS-6: disk hydration refetches unconditionally, then same-engine ETag reuse is conditional. */ | P1 | Deleted the leading `FS-6: ` test-doc tag; sentence otherwise unchanged. | +| `store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomStoreSubstitutionConformanceTest.kt:189` | /** FS-4 / TD-2: a namespace watermark survives Store replacement and forces a refetch. */ | P1 | Deleted the leading `FS-4 / TD-2: ` test-doc tags; sentence otherwise unchanged. | +| `store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomStoreSubstitutionConformanceTest.kt:244` | /** FS-4: clear removes both the user row and its durable freshness record. */ | P1 | Deleted the leading `FS-4: ` test-doc tag; sentence otherwise unchanged. | +| `store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomStoreSubstitutionConformanceTest.kt:274` | /** FS-4: clearNamespace runs the user delete and sweeps matching durable metadata only. */ | P1 | Deleted the leading `FS-4: ` test-doc tag; sentence otherwise unchanged. | +| `store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomStoreSubstitutionConformanceTest.kt:307` | /** FS-1 / FS-5: StaleIfError serves residence, reports failure, and remains causal-live. */ | P1 | Deleted the leading `FS-1 / FS-5: ` test-doc tags; sentence otherwise unchanged. | +| `store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomStoreSubstitutionConformanceTest.kt:378` | /** FR-10 / FS-3: a remote deletion cannot satisfy MustBeFresh on a missing row. */ | P1 | Deleted the leading `FR-10 / FS-3: ` tags. `FR-10` is not itself sweep-matched but is the same internal-referent family as the co-located `FS-3` tag it was joined to; both removed together for a self-contained sentence. | +| `store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomStoreSubstitutionConformanceTest.kt:400` | /** FR-10 / FS-3: LocalOnly hydrates a user-seeded Room row without fetching. */ | P1 | Deleted the leading `FR-10 / FS-3: ` tags, same rationale as :378 above. | +| `store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomStoreSubstitutionConformanceTest.kt:417` (Sweep v2) | /** AC-2-lite: concurrent public gets share one cold-key fetch. */ | P1 | Deleted the leading `AC-2-lite: ` test-doc tag; sentence otherwise unchanged. | +| `store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomStoreSubstitutionConformanceTest.kt:466` | /** FS-3: MaxAge uses durable write time after Store replacement and withholds stale data. */ | P1 | Deleted the leading `FS-3: ` test-doc tag; sentence otherwise unchanged. | +| `store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomStoreSubstitutionConformanceTest.kt:56` | /** FS-1 / AC-1: a cold public Store stream persists its fetched value through Room. */ | P1 | Deleted the leading `FS-1 / AC-1: ` tags (also a Sweep-v2 `AC-1` hit); sentence otherwise unchanged. | +| `store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomStoreSubstitutionConformanceTest.kt:619` (Sweep v2) | // 017 residual-deadline repair: Turbine's 3s default nested inside the 25s shadow; raise the | P1 | Companion line to :620 below; both edited as one two-line comment, same rewrite as ReaderSemanticsTest.kt:648-649. | +| `store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomStoreSubstitutionConformanceTest.kt:620` | // Turbine deadline above the shadow so runTest provides the only effective timeout (D0, PR #15). | P1 | Deleted ` (D0, PR #15)`; combined with :619 above, same rewrite as ReaderSemanticsTest.kt:648-649. | +| `store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomStoreSubstitutionConformanceTest.kt:87` | /** FS-6: a fresh durable row and sidecar let a new Store skip fetching. */ | P1 | Deleted the leading `FS-6: ` test-doc tag; sentence otherwise unchanged. | +| `store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomTransactionalSourceOfTruthTest.kt:385` (Sweep v2) | // 017 residual-deadline repair: Turbine's 3s default nested inside the 25s shadow; raise the | P1 | Companion line to :386 below; both edited as one two-line comment, same rewrite as ReaderSemanticsTest.kt:648-649. | +| `store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomTransactionalSourceOfTruthTest.kt:386` | // Turbine deadline above the shadow so runTest provides the only effective timeout (D0, PR #15). | P1 | Deleted ` (D0, PR #15)`; combined with :385 above, same rewrite as ReaderSemanticsTest.kt:648-649. | + +#### `store6-sqldelight` (4 rows: 4 baseline + 0 Sweep-v2 additions) + +| file:line | excerpt | class (P1-P4/FP/Unverifiable) | action taken | +| --- | --- | --- | --- | +| `store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightBookkeeper.kt:35` | * This seam remains FREEZE CANDIDATE awaiting Matt signature. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightSourceOfTruth.kt:35` | * Every user-row mutation and its matching TD-6 metadata mutation execute in one [Transacter] | P1 | Deleted `TD-6 `; sentence otherwise unchanged. | +| `store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightSourceOfTruth.kt:62` | * Seam status: FREEZE CANDIDATE awaiting Matt signature; never frozen. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/MetaSidecar.kt:14` | * Adapter-owned durable sidecar (TD-6). Four tables are created and versioned by the adapter in | P1 | Deleted `(TD-6)`; sentence otherwise unchanged. | + +#### `store6-compose` (9 rows: 4 baseline + 5 Sweep-v2 additions) + +| file:line | excerpt | class (P1-P4/FP/Unverifiable) | action taken | +| --- | --- | --- | --- | +| `store6-compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/CollectAsState.kt:26` | * Closed-store behavior (finalized by issue 007): calling this on a closed store fails the | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/CollectAsStateWithLifecycle.kt:24` (Sweep v2) | * catches up without a Loading reset). Under the landed issue-007 bounded registry a paused | P4 | Rewrote `the landed issue-007 bounded registry` -> `the bounded key registry`, naming the mechanism directly instead of via the internal issue tag. Verified: `KeyRegistry` (`store6-core/internal/KeyRegistry.kt`) is bounded by `StoreBuilder.maxIdleKeys`, the same `maxIdleKeys` this KDoc's next sentence already names. | +| `store6-compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/StoreResultEquivalence.kt:33` | * issue-007 OQ-1 ruling — same-kind latest-wins, never merged across kinds — whose public | P4 | Deleted `as landed by the issue-007 OQ-1 ruling`; kept `This mirrors the engine's conflateLatestData discipline — same-kind latest-wins, never merged across kinds — whose public contract reads: …`. The mirrored claim is `conflateLatestData`'s own documented behavior, verified against its KDoc in `store6-core/internal/StoreResultFlows.kt` and the quoted `Revalidated` contract text in `store6-core/StoreResult.kt`. | +| `store6-compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/StoreResultEquivalence.kt:39` | * convenience for stateIn/ViewModel consumers; the engine's TD-8 operator rule | P1 | Deleted `TD-8 `; sentence otherwise unchanged (the parenthetical `(conflateLatestData as its single custom operator)` already carries the rationale, verified: `conflateLatestData` is the sole function in `store6-core/internal/StoreResultFlows.kt`, the module's only custom-Flow-operator file). | +| `store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/ClosedStoreBehaviorTest.kt:20` | * exact `Store.stream` seam they call. Close semantics were finalized by issue 007; the close | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/ClosedStoreBehaviorTest.kt:21` (Sweep v2) | * ABI (OQ-3), so no message text is asserted here. | P1 | Deleted `(OQ-3)`; sentence otherwise unchanged. Verified: the close message is `storeClosedException()`'s `STORE_CLOSED_MESSAGE` (`store6-core/internal/StoreLifecycle.kt`), an internal diagnostic string not part of the public ABI. | +| `store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/ClosedStoreBehaviorTest.kt:66` (Sweep v2) | // Issue-017 convention: one file-private 25s runTest shadow, no nested wall-clock waits. | P1 | Deleted the leading `Issue-017 convention: ` tag; sentence otherwise unchanged (capitalized `One` to open the sentence). | +| `store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/ComposeTestHarness.kt:30` (Sweep v2) | * hop, no nested `withTimeout` (issue-017 test-runtime discipline). Every frame is pumped | P1 | Deleted `(issue-017 test-runtime discipline)`; sentence otherwise unchanged. | +| `store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/ComposeTestHarness.kt:67` (Sweep v2) | // Issue-017 convention: one file-private 25s runTest shadow, no nested wall-clock waits. | P1 | Deleted the leading `Issue-017 convention: ` tag; sentence otherwise unchanged (capitalized `One` to open the sentence). | + +#### `store6-mutations-sqldelight` (1 row: 0 baseline + 1 Sweep-v2 addition) + +| file:line | excerpt | class (P1-P4/FP/Unverifiable) | action taken | +| --- | --- | --- | --- | +| `store6-mutations-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/SqlDelightMutationRestartWalkingTest.kt:131` (Sweep v2) | fetcher { error("The SQLDelight AC-4 LocalOnly scenario must not fetch") } | FP | `AC-4` sits inside an `error(...)` string literal in executable test code (a fetcher stub's failure message) — protected content, out of doc-pass charter. No change. | + +--- + +## Task 6 — Testing/devtools modules, test sources, unpublished modules + +Scope: all baseline hits in `store6-testing`, `store6-mutations-testing`, `store6-devtools`, `store6-devtools-inspector`, and the unpublished modules (`store6-benchmarks`, `store6-quickstart`, `store6-extension-probe`, `store6-compose-demo`, `store6-devtools-demo`) — full per-module lists (Task 2 above resolves the `signs off` stamp subset in `store6-testing` first). Per Amendment A1, this task also runs a fresh Sweep-v2 enumeration (hard: `engine-design|design §|§[0-9]+|\bTEST-[0-9]+\b|\bC-[0-9]{2}\b|\bAC-[0-9]+\b|\bOQ-[0-9]+\b|\brow-7/8\b`; classify-only: `\b0(0[1-9]|1[0-9]|2[0-9])\b|\bR[0-9]\b|\bT2E\b`) plus the `ruling|ruled|adopted shape|erratum` classify-only pattern over all nine module trees, deduplicated against the Task 1 baseline. Result: **9 new rows** — 4 in `store6-testing` (v2 classify-only: `TestStoreResults.kt:15`, `FakeBookkeeperAlgebraTest.kt:21`, `FakeFetcherIntegrationTest.kt:31`, `FakeStoreConformanceTest.kt:277`), 1 in `store6-devtools` (v2 classify-only `Issue-017` in `StoreDevtoolsMonitorIntegrationTest.kt:105`), 1 in `store6-benchmarks` (v2 hard `OQ-6` in `TelemetryAllocationProbe.kt:18`), 1 in `store6-compose-demo` (v2 hard `OQ-6` in `Main.kt:15`; the `StabilityProbe.kt:16` row was already an unclassified Task-1 baseline row, not new), 2 in `store6-devtools-demo` (v2 classify-only: `DemoApp.kt:54`, `DemoFetcherTest.kt:54` — this module had zero Task-1 baseline hits). `store6-devtools-inspector`, `store6-quickstart`, and `store6-extension-probe` return zero hits under both sweeps and the ruling pattern; no rows. + +Also resolved: five named residue items pre-authorized by the controller from earlier task reviews (not sweep hits at enumeration time in four of five cases — see the dedicated subsection after the module tables). + +Post-pass state (all nine module trees plus the five named items): v1 hard **0** (one recorded FP survivor, test-fixture data — see `store6-testing` table), v2 hard **0**, v2 classify-only **0**, ruling classify-only **0**. Full-tree (`store6-*/src`) re-run after this task's edits: v1 hard **0** except the same recorded FP; v2 hard **0** except the two `AC-4` `error(...)`-string-literal FPs already recorded by Tasks 4 and 5 (`MutationRestartWalkingTest.kt:100`, `SqlDelightMutationRestartWalkingTest.kt:131`); v2/ruling classify-only survivors are all previously recorded FPs (Task 3b: `SourceOfTruthHydrationRaceTest.kt:97`, `KeyEnginePlanningTest.kt:3440`; Task 4: `MutationInspection.kt:48`/`:62`, `MutationInspectionTest.kt:198`/`:265`, `MutationApiSurfaceTest.kt:29`). + +Class counts for rows classified by this task: **P1 16 · P4 7** (23 rows: 20 module-scope + 3 named-residue `store6-core` rows). Carried-forward rows already resolved by Task 2 (P1/P2/P3/FP) are marked `(Task 2)` and not re-counted here. + +#### `store6-testing` (16 baseline hits + 4 Sweep-v2 additions = 20 rows) + +| file:line | excerpt | class (P1-P4/FP/Unverifiable) | action taken | +| --- | --- | --- | --- | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStore.kt:274` | * Close semantics finalized by issue 007. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStore.kt:40` | * Invalidation implements Decision #37 (Matt, 2026-07-20): it is a stale-mark only and never | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStore.kt:437` | * Decision #37 (ruled by Matt, 2026-07-20): invalidate is a stale-mark only, the engine's | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStore.kt:485` (Task-1 baseline recorded this as `:487`; Task 2's edit shifted it) | // Core keeps STORE_CLOSED_MESSAGE internal by design (FS-5 — | P1 | Deleted the `FS-5 — ` tag/connector inside the parenthetical (Task 2 already removed the preceding `Finalized by issue 007: `). Result: `(diagnostics are review-gated text, not ABI)`; the rationale clause is unchanged and self-contained. | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStore.kt:59` | * The seam consumed here is a FREEZE CANDIDATE, not frozen: freeze sign-off remains held until | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStore.kt:60` | * issue 007 lands and Matt signs off. [close] is synchronous and idempotent. Active collectors are | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStore.kt:63` | * text were finalized by issue 007 against the engine's close lifecycle. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/SourceOfTruthContractKit.kt:22` | * Conformance kit for [SourceOfTruth] implementations (TD-15). Extend it in your test source set, | P1 | Deleted ` (TD-15)`; the sentence is otherwise unchanged. | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeStoreConformanceTest.kt:191` | // THE Decision #37 alignment pin (ruled by Matt, 2026-07-20): with NO active demand, | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeStoreConformanceTest.kt:32` | * There is no invalidate-divergence row: Decision #37 (Matt, 2026-07-20) aligned the fake to the | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeStoreConformanceTest.kt:375` | // Finalized by issue 007: pins verified against StoreCloseLifecycleTest in store6-core. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeStoreConformanceTest.kt:386` | // Finalized by issue 007: pins verified against StoreCloseLifecycleTest in store6-core. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeStoreConformanceTest.kt:83` | assertTrue(ex.message!!.contains("test/1")) // FS-5: which key | P1 | Deleted the `FS-5: ` tag prefix, leaving `// which key`. | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeStoreConformanceTest.kt:84` | assertTrue(ex.message!!.contains("enqueueFetchValue")) // FS-5: the fix | P1 | Deleted the `FS-5: ` tag prefix, leaving `// the fix`. | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/UserViewModelSampleTest.kt:45` | fake.enqueueFetchValue(key, User("42", "Matt")) | FP (Task 2) | No change — test-fixture data literal, not documentation. See Task 2 table for detail. | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/UserViewModelSampleTest.kt:49` | assertEquals("Matt", assertIs(awaitItem()).name) | FP (Task 2) | No change — test-fixture data literal, not documentation. See Task 2 table for detail. | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/TestStoreResults.kt:15` (Sweep v2) | * [StoreResults] door (008 — the sanctioned construction path; StoreResult, StoreError, and | P1 | Deleted `008 — `; kept `(the sanctioned construction path; StoreResult, StoreError, and StoreException constructors are internal)`. | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeBookkeeperAlgebraTest.kt:21` (Sweep v2) | assertFalse(status.durablyStale) // 006 pin: no success + no covering mark -> false (?: 0 zero floor) | P1 | Deleted the `006 pin: ` tag prefix; the assertion note is otherwise unchanged. | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeFetcherIntegrationTest.kt:31` (Sweep v2) | assertEquals("v1", store.get(key, Freshness.MustBeFresh)) // 006 pin: MustBeFresh + resident etag -> | P1 | Deleted the `006 pin: ` tag prefix; the two-line comment (concluded on line 32, unedited) is otherwise unchanged. | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeStoreConformanceTest.kt:277` (Sweep v2) | assertFalse(data.isStale) // 304 cleared staleness (006 pin) | P1 | Deleted the ` (006 pin)` suffix; `304` is domain terminology (the HTTP not-modified status, which is what the assertion is about), not an internal referent. | + +#### `store6-mutations-testing` (1 hit at baseline) + +| file:line | excerpt | class (P1-P4/FP/Unverifiable) | action taken | +| --- | --- | --- | --- | +| `store6-mutations-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/testing/MutatorPurityContractKit.kt:126` | * Published TD-12 conformance kit for durable mutator projectors. | P1 | Deleted `TD-12 `, leaving `Published conformance kit for durable mutator projectors.` (`Published` describes public visibility, not provenance.) | + +#### `store6-devtools` (2 baseline hits + 1 Sweep-v2 addition = 3 rows) + +| file:line | excerpt | class (P1-P4/FP/Unverifiable) | action taken | +| --- | --- | --- | --- | +| `store6-devtools/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtools/CompositeStoreTelemetry.kt:15` | * `telemetry(storeTelemetryOf(logger, monitor))`. This is FS-10's multiplex posture: | P4 | Rationale was expressed only via the `FS-10` referent. Restated from the component itself: deleted `This is FS-10's multiplex posture:` and capitalized the following clause, so the sentence reads `Extension vocabularies and apps may share one application sink without adding methods to the core interface.` Verified against the class: `CompositeStoreTelemetry` fans one installation out to a caller-supplied `List` (constructor param `sinks`), exactly the sharing behavior described. | +| `store6-devtools/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtools/StoreDevtoolsEvent.kt:13` | * decided: values never cross this seam. The seam remains a freeze candidate and sign-off is held. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-devtools/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtools/StoreDevtoolsMonitorIntegrationTest.kt:105` (Sweep v2) | // Issue-017 convention: one file-private 25s runTest shadow, no nested wall-clock waits. | P1 | Deleted the `Issue-017 convention: ` tag prefix, leaving `// One file-private 25s runTest shadow, no nested wall-clock waits.` Matches the identical rewrite Task 5 already applied to the same sentence in `store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/ComposeTestHarness.kt:66` and `ClosedStoreBehaviorTest.kt:66`. | + +#### `store6-devtools-inspector` (0 hits — confirmed under v1, v2, and the ruling pattern) + +No hits in this module. + +#### `store6-benchmarks` (5 baseline hits + 1 Sweep-v2 addition = 6 rows) + +| file:line | excerpt | class (P1-P4/FP/Unverifiable) | action taken | +| --- | --- | --- | --- | +| `store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/StreamEmissionBenchmark.kt:24` | * METRIC-1: stream-emission overhead versus the raw SoT flow (NFR-8, TD-8, TEST-7). | P1 | Deleted the whole parenthetical `(NFR-8, TD-8, TEST-7)`, leaving `* METRIC-1: stream-emission overhead versus the raw SoT flow.` `NFR-8` is not itself matched by the sweep regex but is the same internal-referent family as the co-located `TD-8`/`TEST-7` tags, so it was removed with them. `METRIC-1` is left unchanged — it is a label this file defines and uses consistently for itself (also at line 33), not an outward-pointing tracker referent. | +| `store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/SubscriptionChurnBenchmark.kt:21` | * collections — issue 007's OQ-5 explicitly deferred grace tuning (and retry-backoff shape) to | P3 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/TelemetryOverheadBenchmark.kt:24` | * The measured half of FS-10's "zero cost when unset" (008's deferral; StoreTelemetryTest.kt:114). | P4 | Rationale for the `StoreTelemetryTest.kt:114` cross-reference was expressed only via `FS-10`/`008's deferral`. Restated in terms of the component: `The measured half of the telemetry "zero cost when unset" claim; the allocation-count half is TelemetryAllocationProbe (see StoreTelemetryTest.kt:114).` Verified: `TelemetryAllocationProbe` is the class at `store6-benchmarks/src/test/.../TelemetryAllocationProbe.kt`, and it performs exactly that measurement. | +| `store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/TelemetryOverheadBenchmark.kt:26` | * FS-10's evidence is measured plus structural, not a literal differential against a telemetry-free | P1 | Replaced `FS-10's` with `This`, self-referencing the claim named in the immediately preceding (line-24) sentence. Rest of the sentence unchanged. | +| `store6-benchmarks/src/test/kotlin/org/mobilenativefoundation/store6/benchmarks/TelemetryAllocationProbe.kt:13-14` | * Allocation evidence for FS-10's measured-plus-structural zero-cost-when-unset claim — the / * "allocation-count measurement" StoreTelemetryTest.kt:114 defers to this module. Reports | P4 | **Also named item, Task 3b review (item 1):** the class KDoc claimed `StoreTelemetryTest.kt:114` "defers to this module," but Task 3b's edit of that line (see Task 3b table) changed its wording from "is deferred to" to "lives in" — the class should describe itself, not the stale deferral framing. Deleted `FS-10's` and rewrote self-containedly: `Allocation evidence for the measured-plus-structural zero-cost-when-unset claim: this module performs the allocation-count measurement StoreTelemetryTest.kt:114 references.` Verified: `TelemetryAllocationProbe.residentServe_callerThreadAllocationDelta_reported` measures caller-thread allocated bytes/op, matching the description. | +| `store6-benchmarks/src/test/kotlin/org/mobilenativefoundation/store6/benchmarks/TelemetryAllocationProbe.kt:18` (Sweep v2) | * REPORT-ONLY by design: prints a table, asserts nothing numeric (thresholds are OQ-6/first-data | P1 | Deleted the `OQ-6/first-data` referent and restated the meaning it carried: `(thresholds are OQ-6/first-data territory)` -> `(no threshold is defined yet)`. Verified: the class only `println`s a table and never asserts a numeric bound, consistent with "no threshold defined." | + +#### `store6-quickstart` (0 hits — confirmed under v1, v2, and the ruling pattern) + +No hits in this module. + +#### `store6-extension-probe` (0 hits — confirmed under v1, v2, and the ruling pattern) + +No hits in this module. (The brief flagged this module as a likely `row-7/8`-family carrier since `CoordinatedTransactionalSourceOfTruth.kt` implements the decorator protocol `TransactionalSourceOfTruth.kt`'s deleted paragraph described — per Task 3b's concern note. On inspection its own KDoc documents that protocol in terms of its own components already, with no banned referent.) + +#### `store6-compose-demo` (2 baseline hits + 1 Sweep-v2 addition = 3 rows) + +| file:line | excerpt | class (P1-P4/FP/Unverifiable) | action taken | +| --- | --- | --- | --- | +| `store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/Main.kt:12` | // Process-scoped store on the landed bounded-registry engine (issue 007): idle key engines | P1 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/Main.kt:15` (Sweep v2) | // window exit tears the JVM down, and core's close carries a GC-fallback posture (OQ-6). | P1 | Deleted ` (OQ-6)`; the sentence is otherwise unchanged. | +| `store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/StabilityProbe.kt:16` | * parameters; the gate posture for these follows the T6 calibration ruling recorded in | P4 | The "Iface tier" gate-posture rationale was expressed only via `the T6 calibration ruling` plus a dangling path, `docs/v6/decisions/store6-compose-packaging.md` — confirmed absent from the tree (`git ls-files docs/v6` returns nothing; no `decisions/` directory exists anywhere). Unlike the sibling "Strict tier" sentence, no code-verifiable rationale for the tier's gate posture could be found (no CI workflow or stability-conf entry distinguishes an "Iface tier" threshold). Per the P4 rule, deleted the unverifiable rationale and kept the structural contract: `Iface tier: interface/abstract-typed parameters.` — verified against the file's own `ProbeIface*` functions, which do take interface/abstract-typed parameters (`StoreResult`, `Freshness`, `StoreKey`, `StoreMeta`, `StoreError`). | + +#### `store6-devtools-demo` (0 baseline hits + 2 Sweep-v2 additions = 2 rows) + +| file:line | excerpt | class (P1-P4/FP/Unverifiable) | action taken | +| --- | --- | --- | --- | +| `store6-devtools-demo/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/DemoApp.kt:54` (Sweep v2) | /** Live knobs the demo screen mutates while the store keeps fetching (the 012 demo pattern). */ | P1 | Deleted the whole parenthetical `(the 012 demo pattern)`, leaving a self-contained sentence. | +| `store6-devtools-demo/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/DemoFetcherTest.kt:54` (Sweep v2) | // Issue-017 convention: one file-private 25s runTest shadow, no nested wall-clock waits. | P1 | Deleted the `Issue-017 convention: ` tag prefix; same rewrite as `store6-devtools/.../StoreDevtoolsMonitorIntegrationTest.kt:105` above and the store6-compose precedent (Task 5). | + +#### Named residue items (pre-authorized, Task 3b review) — items 2-4 of 5 + +Item 1 (`TelemetryAllocationProbe.kt:14`) is resolved above in the `store6-benchmarks` table (same hunk as its `FS-10` sweep hit). Item 5 is an inventory-only relabel of the Task 3b table's `StoreTelemetryTest.kt:114` row (see the amendment at the end of the Task 3b section, above). Items 2-4 are not sweep hits — none of the removed phrases match Sweep v1, v2, or the ruling pattern — and are resolved here as controller-named residue, all in already-cleaned `store6-core` files: + +| file:line | excerpt | class (P1-P4/FP/Unverifiable) | action taken | +| --- | --- | --- | --- | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreDurableMaintenanceConformanceTest.kt:14-15` | * This proves store-instance-independent durable facts, not on-disk or cross-process durability, / * which the persistence adapter modules cover. | P4 | Named item, Task 3b review (item 2). The coverage claim overclaimed: `SqlDelightDurableMaintenanceTest` and `RoomStoreSubstitutionConformanceTest` (the persistence adapter tests Task 3b's P4 note on this same file identified) run in-process against a real driver/on-disk store — they do not prove cross-process durability. Dropped `or cross-process` from the claim: `not on-disk durability, which the persistence adapter modules cover.` The exclusion this fixture's own claim makes (store-instance-independent facts only, verified via `InMemoryBookkeeper` and an in-memory SoT) is unchanged and accurate. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConformanceTest.kt:89` | // (d) pins get's posture: a resident value is served without a refetch | P4 | Named item, Task 3b review (item 3). Dropped the vague `pins get's posture: ` clause, keeping `// (d) a resident value is served without a refetch` — capitalization (lowercase after the `(d)` tag, no trailing period) matches the sibling labels at lines 26, 41, 55, 64, 109. Pinned by the test the comment labels, `getAfterStreamCommitted_servesResidentValueWithoutRefetch`. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyRegistry.kt:23` | * - Bulk sweeps retain each snapshotted engine for the action's duration, preserving the landed / * double-sweep-under-fence semantics: ... | P4 | Named item, Task 3b review (item 4) — flagged as residue by Task 3b's concerns note (its item 3). Deleted the landing-state adjective `landed`, leaving `preserving the double-sweep-under-fence semantics: ...` The mechanism name `double-sweep-under-fence` stays: it describes the code (the watermark-plus-fence behavior the same sentence goes on to state) and is not an organizational-status reference. | + +--- + +## Task 7 — Public-surface KDoc quality audit (alpha01 artifacts) + +Not sweep-driven (this is a read-only audit of public-declaration KDoc completeness/quality against the interface-documentation checklist, over `store6-core`, `store6-mutations`, `store6-testing`, `store6-sqldelight`, `store6-room`, `store6-compose`), so no baseline rows apply here. Task 7 appends its own findings table using columns `file:line | class | evidence | remediation` (finding classes: Missing / Stale / Contradictory / Duplicated / Unverifiable / Misleading / Unnecessary / Sufficient) when it runs. + +### Coverage and method + +Audited: every `public` declaration in the `commonMain` source sets of the six alpha01 artifacts (explicitApi mode spells every intended-public symbol `public`). Declaration counts are `grep -c` of `public` occurrences per module `commonMain`, which is the population the audit walked file by file: + +| module | public declarations audited / total | files with public surface | actionable finding rows | +| --- | --- | --- | --- | +| `store6-mutations` | 333 / 333 | 9 of 13 (`MutationEngine`, `MutationJournal`, `MutationBookkeeper`, `MutationSourceOfTruth` are internal-only) | 10 | +| `store6-core` | 164 / 164 | 22 of 43 (the whole `core/internal` package is internal-only) | 13 | +| `store6-testing` | 79 / 79 | 10 of 10 | 10 | +| `store6-room` | 35 / 35 | 6 of 7 (`RoomStoreMeta` is internal-only) | 1 | +| `store6-sqldelight` | 11 / 11 | 2 of 7 (the whole `sqldelight/internal` package is internal-only) | 1 | +| `store6-compose` | 6 / 6 | 3 of 3 | 4 | +| other (`store6-mutations-testing`, cross-module FP) | — | — | 2 | +| **total** | **628 / 628** | **52** | **41** | + +Row counts include the cross-module `landed` section below, whose five rows are attributed here to the module each site lives in. **One row can cover many declarations** (`MutationFailureKind` is nine; the nine journal record classes are 92 properties), so row counts are not declaration counts — see the declaration accounting below. + +Zero-KDoc `commonMain` files, checked for intended-public status first: `store6-core/.../internal/MaintenanceCoordinator.kt` (private + internal only — correctly none), `store6-room/.../RoomStoreMeta.kt` (internal only — correctly none), `store6-testing/.../FakeStoreInteraction.kt` (**public, 10 declarations, no KDoc** — recorded below). The brief's fourth file was in `store6-devtools-inspector`, outside the alpha01 audit set. + +Fix classes applied: Missing / Stale / Contradictory / Misleading / Unnecessary. Rows marked **recorded, not fixed** are honest deferrals under the plan's warrant rule (no claim may be written that could not be verified against code in this pass). + +### Findings — `store6-mutations` + +| file:line | declaration | class | evidence | remediation | +| --- | --- | --- | --- | --- | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspection.kt:32` | `MutationFailureKind` (9 enum entries) | Missing | The nine constants carry no per-entry documentation and the enum KDoc states only the collective purpose, yet `MutationFailure.kind` is reachable from `DeadLetter.failure`, `MutationFailed.failure`, `MutationParked.failure`, and `MutationCheckpointFailed.failure` — a caller branching on the kind has to read the engine. Producer sites pin every constant: IDENTITY `MutationEngine.kt:1137,1482,3192` (resolver null / mismatch / throw, details at `MutationEngine.kt:64-72`), CODEC `:421,:580,:2452` (details `value-codec-pre-ack`, `value-codec-acked`, `args-codec`, `mutator-missing` at `:49-52`), PROJECTION `:1962` (`projection-throw`), PROTOCOL `:799,:2758,:3885` and `MutationProtocol.kt:369`, CONFLICT `:2206,:2326,:2392` (`conflict-unchanged-bound`, `merge-failed`, `selector-failed`), TRANSPORT `:782,:2131` (`retire-failed`, `push-failed`), ADOPTION `:2988,:3005` (`adoption-failed`), EFFECT `:3116` (`effect-target-failed`), PERSISTENCE `:827,:860` (`retire-confirmation-failed`, `retire-prune-failed`). | Fixed: one KDoc line per constant, each naming only the failure source proven by its producer sites. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:372` | `mutationStore(...)` | Missing | `MutationStoreBuilder.snapshot()` (`MutationStoreBuilder.kt:182-185`) throws `IllegalArgumentException` when `configure` installs no fetcher door, and the factory's own `require(valueCodecVersion >= 1)` (`MutationStore.kt:380`) throws for a non-positive codec version. Neither is documented, while the mirror core factory `store()` does document its equivalent (`StoreBuilder.kt:29`). | Fixed: added the two `@throws IllegalArgumentException` clauses, matching core's wording and tag style. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:219` | `MutationStore.mutate` | Missing | The function returns `String` and the KDoc never says what it is. `MutationEngine.mutate` returns `mutationId` (`MutationEngine.kt:686`), the same opaque id documented on `PendingIntent.mutationId` and every `MutationIntentEvent`. `MutationEngine.kt:616-618` also throws `IllegalArgumentException` for a `MutatorRef` from another registry. | Fixed: added `@return` for the mutation id and `@throws IllegalArgumentException` for the foreign-registry ref. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEvents.kt:294` | `MutationEventBus` (named extra 4) | Unnecessary | "Channels and actors are banned as protocols" asserts a prohibition with no stated rationale and no code referent; the sentence that follows ("Emission is [tryEmit]-only, so lifecycle work can never block or suspend on telemetry…") already carries the whole contract, pinned by `MutationEventBus.tryEmit` (`MutationEvents.kt:313`) and the `replay = 0 / extraBufferCapacity = 64 / DROP_OLDEST` construction at `:303-307`. | Fixed: dropped the prohibition clause, kept the mechanism sentence ("A `MutableSharedFlow` configured with [BufferOverflow] carries the events."). | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/storage/MutationJournalRecords.kt:11` | `MutationExecutionPhase` (8 enum entries) | Missing | No per-entry documentation. Six of the eight already have a published meaning through `MutationPendingState`'s mapping KDoc (`MutationInspection.kt:17-19`); the two terminal ones are pinned by `MutationExecutionRecord`'s init block (`MutationJournalRecords.kt:130-135`: `activeFailureId` exists exactly in `PARKED`, `retiredAt` exactly in `RETIRED`). | Fixed: added one paragraph naming the two terminal phases and pointing the six nonterminal ones at the `MutationPendingState` mapping. No per-entry prose invented. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/storage/MutationJournalRecords.kt:54,78,108,146,218,258,282,307,330` | the nine durable record classes (92 public properties) | Missing | Every record class carries a one-line class KDoc and then declares its properties with no documentation at all (only `argsBlob`, `baseBlob`, `mineBlob`, `authoritativeBlob` — the copying accessors — were documented). A storage implementer cannot derive `idempotencyRoot`, `advertisedRetiredThroughSequence`, `activeFailureId`, `preconditionMetaPresent`/`preconditionWrittenAt`, `recordVersion`, or `rowId` from the names. | **Fixed in part (10 of 92 documented: 4 pre-existing blob-accessor properties — `argsBlob`, `baseBlob`, `mineBlob`, `authoritativeBlob` — plus 6 fixed in this pass), remainder recorded.** Review correctly noted that `MutationExecutionRecord`'s `init` block already pins six fields, so no verification work was outstanding for them: `clientSequence` (`:126` positive), `currentGeneration` (`:127` non-negative, `:143-147` zero only in `UNPREPARED` or a never-prepared `PARKED`), `attempt` (`:128` non-negative, `:132-136` zero while the generation is zero), `lastAttemptAt` (`:129-131` non-null exactly when `attempt` is nonzero), `activeFailureId` (`:137-139` non-null exactly in `PARKED`; the value is a `MutationFailureRecord.failureId`, pinned by `MutationEngine.kt:2243,:2518,:2781`), and `retiredAt` (`:140-142` non-null exactly in `RETIRED`). One KDoc line each was written from those pins; times are Unix epoch milliseconds per `MutationJournalStorage`'s own contract. The remaining 82 properties stay deferred, and the deferral is now narrower and honest: they are the fields whose contracts are **not** pinned by an `init` `require` — `idempotencyRoot`, `advertisedRetiredThroughSequence`, `recordVersion`, `rowId`, the alias/tombstone provenance fields, and the precondition/conflict receipt fields, each of which needs its engine or storage write site read before a claim can be made. Compile-enforced cross-field invariants and the per-operation rules on `MutationJournalStorage` remain the mitigation. Remediation for a follow-up: one line per remaining property, pinned to the call site that writes it. **[Task 8 correction, mechanical recount]** Two earlier figures for this row's documented-property count were wrong: this task's Task 7 narrative said "11 are now documented" (a summation error — never reconciled against the 4 pre-existing + 6 new = 10 actually named above) and a Task 8 controller-resolution draft guessed "6" (reading only the newly-fixed count as the total, missing the 4 pre-existing blob accessors). Task 8 mechanically re-derived the count directly from the file: for every `public val` (92 total), walk backward over blank/pure-annotation lines to the nearest substantive line and call the property documented iff that line ends in `*/`. Result: exactly 10 documented (`argsBlob`, `MutationExecutionRecord.clientSequence`, `currentGeneration`, `attempt`, `lastAttemptAt`, `activeFailureId`, `retiredAt`, `baseBlob`, `mineBlob`, `authoritativeBlob`), 82 undocumented. 92 − 10 = 82, confirming the "remaining 82" above. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:2459,3646,3658` | internal `parkDurableConflictFailure`, cache-replacement and bookkeeping helpers | Stale | `Rows 3/10:` and `C8-15 step 3:` / `C8-15 step 4:` are internal design-table referents naming a document absent from the tree. They match neither Detection sweep v1 nor Sweep v2 (`C8-15` is not `C-\d{2}`; `Rows 3/10` is not `row-7/8`), so Tasks 4 and 3b could not see them. Same family as every P1/P4 hit already removed. | Fixed: deleted the tag prefixes; the sentences are otherwise unchanged and self-contained. Outside the public-declaration charter (these are internal members) but in `commonMain` of an audited module, so recorded and repaired here rather than left for Task 8. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:3767`, `:3853` | `captureBase`, `stageLegacyPresentAck` (named extra 5) | Unnecessary | Ragged wrapping left by Task 4's rewrite: `:3767` fills 65 of ~96 columns and `:3770` fills 83, against a file convention of 95-100 (`MutationEngine.kt` comment-line mode is 95-97). | Fixed: re-wrapped both KDoc blocks to the file convention. Words unchanged — verified by comparing whitespace-normalized text before and after. | +| `store6-mutations/src/commonTest/.../MutationRetirementTest.kt:401`; `MutationPruningRegressionTest.kt:130`; `MutationDrainInvalidationTest.kt:46`; `MutationDrainParkingTest.kt:40` | test-fixture KDoc | Stale | `R-0 rule 9:` (×2), `T5.5's`, and `T2.2's` are internal design-document and test-plan referents naming documents absent from the tree. None matches Detection sweep v1 or Sweep v2, so Task 4's module gate could not see them. | Fixed: deleted the referent prefixes; each sentence is otherwise unchanged and self-contained (`Ordinary prune removes rows only at or below…`, `The durable invalidation executor and…`, `The complete pre-ack parking inventory.`). Outside the public-declaration charter; recorded and repaired under the Tasks 2-6 P1 rule. | + +### Findings — `store6-core` + +| file:line | declaration | class | evidence | remediation | +| --- | --- | --- | --- | --- | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Freshness.kt:49` | `Freshness.LocalOnly` | Stale | "fetcher-less stores arrive with a later release (FR-10)" is a speculative future claim plus an internal requirement tag, on published KDoc. Sweeps v1/v2 do not match `FR-\d+`. The global constraints ban speculative intent outright ("A sentence describing behavior that does not exist in the current tree … is deleted, not rewritten"). The current, code-verified half of the sentence is pinned by `StoreBuilder.build()` (`StoreBuilder.kt:177`), which still requires a fetcher. | Fixed: deleted the future claim and its tag; kept "The builder still requires a fetcher, but LocalOnly never invokes it." | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/TransactionalSourceOfTruth.kt:12` | interface KDoc (named extra 3) | Misleading | After Task 3b deleted the row-7/8 decorator paragraph, "`StoreWriteHandle.confirmFresh` alone is not an observation mechanism." stands as its own paragraph with no referent: `confirmFresh` is named nowhere else in the file, and the deleted paragraph was what made the sentence a warning about a specific sequence (write inside `withTransaction`, then `apply` + `confirmFresh` after commit). | Fixed by relocation, not deletion — this is a behavioral guarantee and must survive verbatim in meaning. Moved to `StoreWriteHandle.confirmFresh`'s own KDoc, where it lands directly after "With no resident value this does nothing." and reads as one contract. Nothing else on either KDoc changed. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FreshnessValidator.kt:16` | `FreshnessContext` (6 public properties) | Missing | Only `status` and the null-`meta` posture are documented; a custom `FreshnessValidator` reads all six to plan a fetch, and `epochStale` is not derivable from its name. Construction sites pin each field: `KeyEngine.kt:1162-1168` and `:2571-2580` — `hasResidentValue = envelope != null`, `meta = envelope?.meta`, `epochStale = envelope != null && envelope.staleEpochAtCommit < `, `freshness` = the policy of the call being planned, `nowEpochMillis` = the wall-clock reading captured for this plan. | Fixed: one KDoc line per property, each stating exactly what the construction site computes. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Bookkeeper.kt:104` | `KeyStatus` (4 of 5 public properties) | Missing | `durablyStale` is documented; `meta`, `lastSuccessSequence`, `lastFailureAtEpochMillis`, and `consecutiveFailures` are not, and a custom `Bookkeeper` must construct all of them. Each is pinned by the corresponding `Bookkeeper` method contract in the same file: `recordSuccess` (`:36-37`) assigns the next shared monotone sequence and clears the failure timestamp and count; `recordFailure` (`:45-46`) records one consecutive failure at `atEpochMillis`; `status` (`:55-56`) returns null when neither a record nor a covering watermark exists. | Fixed: one KDoc line per property, each restating only what its own interface method already guarantees. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreBuilder.kt:82` | `StoreBuilder.fetcherOfResult` | Unnecessary | "A second named function preserves the builder strategy and leaves the quickstart unchanged." narrates a library design decision with no caller consequence, and "the quickstart" is an unresolvable referent (the `store6-quickstart` module, invisible to a consumer). The two neighbouring sentences are caller-relevant and stay: the v5 naming lineage, and the overload-resolution consequence. | Fixed: deleted the one sentence; the rest of the KDoc is unchanged. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/ValueEnvelope.kt:13` | `ValueEnvelope.meta` (named extra 1) | Misleading | "Null meta is the conservative posture" — the definite article was left unmoored when the tag it pointed at was removed; as written it asserts a unique posture the surrounding text never establishes. | Fixed: "Null meta is a conservative posture". Nothing else changed; the three stated consequences (`isStale = true`, age zero, never satisfies demand without revalidation) are untouched. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/StoreResultFlows.kt:23` | `conflateLatestData` (named extra 2) | Misleading | Doubled word: "realizes an O(1)-per-collector bound and closes the lifecycle-signal bound". Pre-cleanup the two nouns were disambiguated by their qualifiers (`FS-1's … bound` / `the lifecycle-signal bound deferred to issue 007`, `801b8e8`); with both qualifiers gone the sentence reads as a repetition. | Fixed: "This realizes an O(1)-per-collector bound that covers lifecycle signals as well as data." — verified against the operator itself, whose pending queue holds at most one element per `StoreResult` kind across all four kinds (`StoreResultFlows.kt:19-20` and the `ArrayDeque` body). | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreResults.kt:16-27` | the 11 factory functions | Missing (minor) | None of the 11 has KDoc, and `exception(error, cause = null)` has an undocumented default. | **Recorded, not fixed.** Every constructed variant documents each of its own properties on `StoreResult` / `StoreError`, one Dokka hop away; a per-factory line would restate the name and types without adding use semantics, which this audit's own standard classifies as Unnecessary. Recorded so the decision is visible rather than silent. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreResults.kt:13`, `:17-27` | file formatting | (not a doc class) | The object KDoc line is 122 columns and the 11 declarations are single-line, all far past the 95-100 column convention every other file in the module holds. | No change. Formatting is not one of this task's fix classes and reflowing executable declarations would breach the comment-only hunk boundary. Reported as a concern for a formatting pass. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Store.kt:138`, `:156` | `clearNamespace`, `clearAll` | (not a doc class) | Two KDoc lines run to 108 and 105 columns against the file's 97-101 mode. | No change; same reasoning as the row above. Named extra 5 scoped the wrap repairs to the three sites the controller named. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthConformanceTest.kt:338` | test comment | Stale | `// FR-10: a pre-populated SoT serves without a fetch under LocalOnly.` — internal requirement tag, invisible to sweeps v1/v2, in published source. | Fixed: deleted the `FR-10: ` prefix. Outside the public-declaration charter; recorded and repaired under the Tasks 2-6 P1 rule. | + +### Findings — `store6-testing` + +| file:line | declaration | class | evidence | remediation | +| --- | --- | --- | --- | --- | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStoreInteraction.kt:9` | `FakeStoreInteraction` + 9 variants | Missing | The whole file has no KDoc (one of the three zero-KDoc `commonMain` files, and the only public one). A consumer reading `FakeStore.interactions` has nothing that says what the list contains or in what order. Pinned by `FakeStore.record` (`FakeStore.kt:292-294`, append-only), the `interactions` accessor (`:98-99`), and `FakeStore.close` (`:274-277`), whose compare-and-set means `Close` is recorded at most once. | Fixed: added a class-level KDoc stating what the type is, that entries are appended in call order, and that `Close` appears at most once. The nine variants take no prose — each names its `Store` operation and carries that operation's own arguments, so per-variant KDoc would be signature narration. | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStore.kt:98,101,105,139,143,151` | `interactions`, `clearInteractions`, `setValue`, `enqueueFetchValue`, `enqueueFetchError`, `enqueueFetchRevalidated` | Missing | The six programming entry points of the fake carry no KDoc; the class KDoc describes the model but never states the per-member contract, and four defaults (`origin = Origin.MEMORY`, `isStale = false`, `servedStale = false`, `age = Duration.ZERO`) are undocumented. Pinned by `enqueue` (`FakeStore.kt:322-328`, per-key append), `applyScript` (`:389-432`, commits with `Origin.FETCHER` and clears staleness), and the CAS consumption sites `consumeIfAbsent`/`consumeIfStale` (`:334`, `:373`). | Fixed: one KDoc block per member, stating only what those functions do. | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeFetcher.kt:64` | `FakeFetcherInvocation` | Missing | Public class, two public properties, no KDoc anywhere. `FakeFetcher.fetch` (`:44-47`) records one per call and `etag` is non-null only when the engine planned a conditional fetch — the fact the class KDoc of `FakeFetcher` already states for the fetcher but not for the record type. | Fixed: added a class KDoc and one line for `etag`'s nullability. | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/TestStoreMeta.kt:7` | `TestStoreMeta` | Unnecessary | "Distinct from the BookkeeperContractKit's private nested TestStoreMeta, which stays private in that file." tells the reader about a declaration that is `private` and therefore can never appear in their completion list or their Dokka. | Fixed: deleted the sentence; the purpose sentence is unchanged. | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/TestWallClock.kt:11` | `TestWallClock` | Unnecessary | "WallClock is a regular interface (not a fun interface) — this is a plain override, no SAM conversion anywhere." narrates this class's own signature; a user of `TestWallClock` takes no action on it, and `org.mobilenativefoundation.store6.core.seam.WallClock` is declared as a plain `public interface` (no `fun` modifier), so the clause only restates what the declaration already shows. | Fixed: deleted the trailing clause, keeping "Controllable [WallClock]: time moves only when a test moves it." | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStore.kt:214,370` | internal comments on `get` and `consumeIfStale` | Stale | `Decision #37` is an internal decision-log referent naming a document absent from the tree; it survives sweeps v1 and v2. The behavior it labels ("return stale residence and commit one queued outcome behind this read") is code-verified and stays. | Fixed: deleted the referent, kept both behavioral sentences. Outside the public-declaration charter (private members); recorded and repaired under the Tasks 2-6 P1/P4 rule. | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStore.kt:271-273` | `FakeStore.close` | Duplicated | "Closes this fake synchronously and idempotently." repeats the class KDoc's `[close] is synchronous and idempotent` (`:59`) verbatim in meaning. | No change. `Duplicated` is not one of this task's fix classes, and the member-level restatement is the one a reader hovering `close()` actually sees. | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeStoreConformanceTest.kt:25,167,334` | test comments | Stale | Three further `Decision #37` referents, same family as the two `commonMain` hits above. | Fixed: deleted the referents, kept the behavioral text; re-wrapped the two comment blocks the deletion left ragged. Outside the public-declaration charter; recorded and repaired under the Tasks 2-6 P1 rule. | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeStoreConformanceTest.kt:206,225,331` | test comments | Stale | Three more referents in the same file, found while repairing the row above: `Second #37 pin:` and `Third #37 pin:` are short forms of the same decision-log referent, and `(approved phase0 item 36)` is an approval-state reference to an internal work item. All three survive sweeps v1 and v2. | Fixed: deleted the referent prefixes and the parenthetical, keeping every behavioral sentence; re-wrapped the two blocks the deletion left ragged. Review follow-up: removing the parenthetical at `:331` left the tautology "Pins the dispatch pin:", now "Pins per-frame dispatch:" — the same phrasing `FakeStore.kt:227` uses for the mechanism it pins ("Per-frame dispatch pin"). Outside the public-declaration charter; recorded and repaired under the Tasks 2-6 P1/P2 rule. | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeBookkeeperAlgebraTest.kt:28` | inline assertion comment (named extra 6) | Unnecessary | "(landed kit pin)" — `landed` is landing-state phrasing, banned as organizational status. | Fixed: "(kit pin)". | + +### Findings — `store6-room` + +| file:line | declaration | class | evidence | remediation | +| --- | --- | --- | --- | --- | +| `store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/RoomBookkeeper.kt:32` | `RoomBookkeeper` | Contradictory | "A future `RoomSourceOfTruth.withTransaction` capability can make both writes atomic without changing the bookkeeping seam." describes as future a capability that exists in this module today: `RoomSourceOfTruth` declares `: TransactionalSourceOfTruth` (`RoomSourceOfTruth.kt:219`) and implements `withTransaction` (`:371`). This is exactly the staleness shape Task 4 found in `store6-mutations`, surviving here because it carries no sweep-matching token. | Fixed by deletion, not restatement. The capability exists, but whether wrapping a value write and a `RoomBookkeeper` write in one `RoomSourceOfTruth.withTransaction` actually makes them atomic depends on Room writer-connection enlistment across two adapters, which this pass could not verify — so under the warrant rule the sentence is removed rather than rewritten into a guarantee. The two preceding sentences, which are the caller-relevant contract (non-atomic durable steps; rehydration treats a metadata-less value as age-unknown/stale), are unchanged. | + +### Findings — `store6-sqldelight` + +| file:line | declaration | class | evidence | remediation | +| --- | --- | --- | --- | --- | +| `store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightSourceOfTruth.kt:75` | `SqlDelightSourceOfTruth` constructor param `wallClock` | Missing | The parameter is nullable with a `null` default and the class KDoc never says what `null` selects. Pinned by `SqlDelightSourceOfTruth.kt:80`: `private val clock = wallClock ?: SqlDelightSystemWallClock`. | Fixed: added one sentence to the dispatching paragraph stating that a null `wallClock` selects the adapter's own system clock. | + +### Findings — `store6-compose` + +| file:line | declaration | class | evidence | remediation | +| --- | --- | --- | --- | --- | +| `store6-compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/CollectAsState.kt:31`; `CollectAsStateWithLifecycle.kt:32`; `StoreResultEquivalence.kt:19` | `collectAsState`, `collectAsStateWithLifecycle`, `storeResultMutationPolicy` | Unnecessary | "The seam this consumes is a FREEZE CANDIDATE, not frozen." is the same per-declaration governance stamp Task 2 deleted at 18 sites across `store6-core`, `store6-devtools`, and `store6-room` (see the Task 2 table). These three survived only because the wording carries no sweep token. Task 2's ruling applies unchanged: the `@ExperimentalStoreApi` annotation on each declaration plus STABILITY.md already carry the public stability contract, so it is not restated per declaration. | Fixed: deleted all three stamp sentences and their orphaned blank KDoc lines. Every other sentence in the three KDocs is unchanged. | +| `store6-compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/CollectAsState.kt:74` | `freshnessToken` GUARD comment | Unnecessary | "every other landed Freshness is a `data object`" — `landed` is landing-state phrasing, banned as organizational status. The guard itself is a real maintenance contract and stays. | Fixed: "every other `Freshness` variant is a `data object`". | +| `store6-compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/CollectAsState.kt:30-31` | `collectAsState` | Unnecessary | "these throws are characterized by type-only tests, never by message text" describes this repository's test strategy; a consumer takes no action on it. The clause before it — that the close message is engine-internal diagnostic text, not ABI — is a real warning to a caller and stays. | Fixed: deleted the trailing clause only. | +| `store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/Main.kt:12` | demo comment (named extra 6) | Unnecessary | "the landed bounded-registry engine" — `landed` is landing-state phrasing, banned as organizational status. | Fixed: "the bounded-registry engine". Outside the alpha01 artifact set (demo module); pre-authorized by the controller as a named extra. | + +### Findings — remaining `landed` adjective (cross-module), found by generalizing named extra 6 + +The two controller-named `landed` sites turned out to be instances of a family. A word sweep for `\blanded\b` over `store6-*/src` found five more in the audited modules, all the same class as Task 3b's named item 4 (`KeyRegistry.kt:23`, already repaired there). + +| file:line | declaration | class | evidence | remediation | +| --- | --- | --- | --- | --- | +| `store6-core/src/commonMain/.../core/internal/KeyEngine.kt:118`, `:2135`, `:4740` | `overlay` constructor param, `applyClearTransitionLocked`, `toData` | Unnecessary | "the landed direct-residence path", "the landed clear transition", "saturating landed age behavior" — in each the adjective adds nothing to the noun it modifies and asserts landing state. | Fixed: deleted the adjective in all three. Each sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/.../mutations/MutationEngine.kt:1147` | inline comment in the keyed-drain resolution branch | Unnecessary | "Keep the landed no-head/later-owned posture." | Fixed: "Keep the no-head/later-owned posture." | +| `store6-core/src/commonTest/.../core/internal/TransitionTest.kt:256` | inline assertion comment | Unnecessary | "the landed Initial-reset bug must not return" | Fixed: "the Initial-reset bug must not return". | +| `store6-mutations-testing/src/commonMain/.../testing/JournalStorageKillPointScenarios.kt:112,119,124`; `MutationJournalStorageContractKit.kt:45` | contract-kit KDoc | Stale | Four `R-0 rule 9` / `R-0's named phase rules` referents, the same internal design-document family repaired in `store6-mutations`' test sources above. `store6-mutations-testing` is published (it has an `api/` dump) but is not in Task 7's six-module audit set, and it was Task 6's disclosure scope. | **Fixed** (controller-authorized after review, so that no known internal referent remains anywhere outside the recorded protected-content FPs — the precondition for the final gate). `R-0 rule 9:` deleted from the `beforePrune_abortsBeforeDeleteAndClears` KDoc, leaving "Ordinary prune removes rows only at or below the persisted server-confirmed prefix; alias redirects and active or pending tombstone generations always survive." The two sibling one-liners now read "The same prune bound, exercised at …", which refers to that stated bound instead of to an absent document. `R-0's named phase rules` became `the named phase rules`; the kit's own next sentence already enumerates them (`INFLIGHT -> READY` legal, `REFRESH_REQUIRED` may advance, `RETIRED`/`PARKED` terminal, `ACKED`/`EFFECTS_PENDING` never regress), so the contract is intact and now self-contained. | +| `store6-testing/src/commonTest/.../UserViewModelSampleTest.kt:49`; `store6-mutations/src/commonTest/.../MutationAckPathTest.kt:72`; `store6-mutations{,-sqldelight}/src/.../Mutation*RestartWalkingTest.kt` | test fixtures | FP | `assertEquals("Matt", …)` is fixture data, `landed.origin` is a local variable name, and `error("The AC-4 LocalOnly scenario must not fetch")` is a string literal. All are protected executable content. | No change. Already recorded as known out-of-charter residue under Amendment A1. | + +### Class totals + +Counted by class over the 41 rows actually present in the tables above (`Missing (minor)` folded into `Missing`): + +| class | rows | fixed | fixed in part | recorded, not fixed | +| --- | --- | --- | --- | --- | +| Missing | 12 | 10 | 1 | 1 | +| Stale | 8 | 8 | 0 | 0 | +| Contradictory | 1 | 1 | 0 | 0 | +| Misleading | 3 | 3 | 0 | 0 | +| Unnecessary | 13 | 13 | 0 | 0 | +| Duplicated | 1 | 0 | 0 | 1 (not a fix class) | +| Unverifiable | 0 | — | — | — | +| FP | 1 | 0 | 0 | 1 (no change warranted) | +| not-a-doc-class (formatting) | 2 | 0 | 0 | 2 (out of fix classes) | +| **total** | **41** | **35** | **1** | **5** | + +Per-module row counts (10 / 13 / 10 / 1 / 1 / 4 / 2) also sum to 41; the two tables agree. + +**Declaration accounting** (rows are not declarations). The fixes revised the documentation of **51 public declarations**: 9 `MutationFailureKind` constants; 6 `MutationExecutionRecord` properties; 5 `FreshnessContext` properties; 4 `KeyStatus` properties; 7 `FakeStore` members; 3 `FakeFetcherInvocation` declarations; 3 `store6-compose` entry points; and one each for `MutationExecutionPhase`, `mutationStore`, `MutationStore.mutate`, `MutationStore.invalidate`, `MutationStore.clear`, `Freshness.LocalOnly`, `StoreBuilder.fetcherOfResult`, `StoreWriteHandle.confirmFresh`, `TransactionalSourceOfTruth`, `RoomBookkeeper`, `SqlDelightSourceOfTruth`, `FakeStoreInteraction`, `TestStoreMeta`, `TestWallClock`. A further **93 declarations** are named by the two recorded-unfixed `Missing` rows: 11 `StoreResults` factories and 82 still-undocumented properties across the nine journal record classes (that file holds 92 public properties, of which 10 are documented — 4 pre-existing blob-accessor properties plus 6 fixed in this pass; see the Task 8 correction on that row above). The remaining **484** carried documentation that met the checklist without change — that is the honest `Sufficient` figure (628 − 51 − 93), replacing both the earlier 595 (derived by subtracting row counts from declaration counts, wrong) and an interim 485 (used the row's uncorrected 81/11 figures). + +**Scope note on rows outside the public-declaration charter.** Thirteen of the 41 rows do not sit on a public declaration: three on internal or private `commonMain` members, seven in `commonTest` fixtures, one in the `store6-compose-demo` module, one in `store6-mutations-testing`, and one cross-module FP row. All but the FP row are the internal-referent family Tasks 2-6 removed, and all survived because their wording matches neither Detection sweep v1 nor Sweep v2 (`FR-\d+`, `R-0 rule N`, `C8-15`, `Rows 3/10`, `T5.5`, `T2.2`, `Decision #37`, `#37 pin`, `phase0 item 36`, `landed`). They were repaired rather than left, because Task 8's gate runs the same two sweeps that already miss them. Every one is a comment-only deletion of a referent prefix with the surrounding sentence unchanged. A follow-up should widen the sweep families rather than rely on reading. + + +--- + +## Task 8 — Final gate, Dokka proof, completion report + +### Full sweep gate (all patterns, `store6-*/src`) + +Sweep v1 hard, Sweep v2 hard, both classify-only patterns (`ruling|ruled|adopted shape|erratum` and +`\b0(0[1-9]|1[0-9]|2[0-9])\b|\bR[0-9]\b|\bT2E\b`), and the extended referent-family check +(`\bFR-[0-9]+\b|\bR-0\b|\bC8-15\b|Decision #|phase0|\bT[0-9]\.[0-9]\b|\blanded\b`) all run to their +required state: **zero unrecorded hits.** Every survivor cross-checks against an inventory FP row — +the `MutationApiSurfaceTest.kt:70` row above is the one addition this task made, ratifying a Task +4 finding that had never been carried into the tables. The permitted-survivor set, confirmed +closed: the `User("42", "Matt")` test fixture (`UserViewModelSampleTest.kt:45,49`), the two `AC-4` +`error(...)`-message string literals (`MutationRestartWalkingTest.kt:100`, +`SqlDelightMutationRestartWalkingTest.kt:131`), the `landed.origin` local variable +(`MutationAckPathTest.kt:71-72`), and the `da72d908 T0.3` SHA-baseline string +(`MutationApiSurfaceTest.kt:70`). + +## Completion report + +### (a) Commits on this branch + +`git log --oneline 801b8e8..HEAD` (newest first): + +``` +4feef22 docs: address Task 7 review findings; clear remaining known referents +144b90e docs: interface KDoc audit fixes for alpha01 artifacts +8c84c16 docs: clear internal references from testing, devtools, and test sources +d3ee926 docs(adapters): remove internal references from room, sqldelight, compose, mutations-sqldelight +5cb26f4 docs(mutations): restate conflict-repeat bound on merge door; wrap fix +47ff4c2 docs(mutations): make KDoc contracts self-contained, drop internal issue references +1d1607a docs(core): clear design-doc referents surfaced by widened sweep +b93a795 docs: amend plan with Sweep v2 and Task 3b (execution amendment A1) +77ef2c2 docs(core): remove internal issue and design-doc references from KDoc +2bf3e5f docs: remove internal sign-off stamp from freeze-candidate KDoc +c25c01d docs: record inventory 4-column format in plan +3f236c5 docs: add source-doc cleanup inventory baseline +``` + +Plus this task's own commit (below). + +### (b) Files changed + +`git diff 801b8e8...HEAD --stat` summary: **113 files changed, 1960 insertions(+), 753 +deletions(-)**. Scoped to the disclosure/quality-pass surface (`store6-*/src`, shell-glob-expanded +pathspec — see the pathspec note under (c)): **111 files changed, 747 insertions(+), 753 +deletions(-)**. The remaining 2 files outside that scope are the plan document and this inventory +file itself, both under `docs/superpowers/plans/`. + +### (c) Gate and verification commands run, with results + +| Command | Result | +| --- | --- | +| `grep -rEn 'TD-[0-9]+\|RISK-[0-9]+\|STORE-[0-9]+\|FS-[0-9]+\|RD-[0-9]+\|\(D[0-9]+[a-z]?\)\|[Ii]ssue [0-9]{2,3}\|PROVISIONAL\|pending [Ii]ssue\|PR #[0-9]+\|linear\.app\|\bMatt\b\|signs? off\|sign-off' --include='*.kt' --exclude-dir=build store6-*/src` (Sweep v1 hard) | 2 hits, both the recorded `User("42","Matt")` FP. Zero unrecorded. | +| `grep -rEn 'engine-design\|design §\|§[0-9]+\|\bTEST-[0-9]+\b\|\bC-[0-9]{2}\b\|\bAC-[0-9]+\b\|\bOQ-[0-9]+\b\|\brow-7/8\b' --include='*.kt' --exclude-dir=build store6-*/src` (Sweep v2 hard) | 2 hits, both recorded `AC-4` `error(...)`-message FPs. Zero unrecorded. | +| `grep -rEn 'ruling\|ruled\|adopted shape\|erratum' --include='*.kt' --exclude-dir=build store6-*/src` (classify-only v1) | 1 hit, recorded FP (`MutationApiSurfaceTest.kt:29`). | +| `grep -rEn '\b0(0[1-9]\|1[0-9]\|2[0-9])\b\|\bR[0-9]\b\|\bT2E\b' --include='*.kt' --exclude-dir=build store6-*/src` (classify-only v2) | 5 hits, all recorded FPs (byte-budget numbers, protected string literals). | +| `grep -rEn '\bFR-[0-9]+\b\|\bR-0\b\|\bC8-15\b\|Decision #\|phase0\|\bT[0-9]\.[0-9]\b\|\blanded\b' --include='*.kt' --exclude-dir=build store6-*/src` (extended referent check) | 3 hits: 2 `landed.origin`, 1 `da72d908 T0.3` — now both recorded FPs (see above). | +| `./gradlew apiCheck ktlintCheck spotlessCheck` | `BUILD SUCCESSFUL` (584 actionable tasks: 64 executed, 520 up-to-date). **Caveat:** `ktlintCheck` and `spotlessCheck` are no-ops for `store6-*` modules — the executed-task log shows ktlint/spotless tasks only for the legacy modules (`cache`, `core`, `multicast`, `rx2`, `store`); root `build.gradle.kts` returns early for `store6-*`. This command proves the legacy modules only. Formatting assurance for `store6-*` rests on per-task hunk review (see below), not this command. | +| `./gradlew :store6-core:dokkaHtml :store6-mutations:dokkaHtml :store6-testing:dokkaHtml :store6-room:dokkaHtml :store6-sqldelight:dokkaHtml :store6-compose:dokkaHtml` | **Unavailable — no such tasks.** Dokka link proof unavailable: the Dokka plugin is applied only to legacy modules (`KotlinMultiplatformConventionPlugin` calls `pluginManager.apply("org.jetbrains.dokka")`); `Store6Conventions` (`configureStore6Module()`, shared by every `store6-*` module) never applies it, so no `dokkaHtml` task exists for any store6-* module — pre-existing (this branch changed no build files: `git diff 801b8e8...HEAD --stat -- '*build.gradle.kts' 'tooling/**'` is empty; the plugin wiring last changed at `69db1ea`/`8c4fc68`, both pre-branch). KDoc link integrity instead rests on per-task manual link verification, including one FQN link correction made during the pass: `MutationJournalRecords.kt:15`'s `[MutationPendingState]` did not resolve (the file's package is `…mutations.storage`, the type lives in the parent package `…mutations`, not imported there — Dokka would render it as plain text) and was fully qualified as `[org.mobilenativefoundation.store6.mutations.MutationPendingState]`, matching the FQN pattern already used at `FakeStoreInteraction.kt:9`. The controller is flagging Dokka wiring for `store6-*` modules as a separate follow-up task outside this branch. | +| `git diff 801b8e8...HEAD -- store6-*/src` (mechanical hunk check) | 1500 changed (`+`/`-`) lines; 1480 mechanically comment/KDoc/blank, 20 flagged as trailing inline comments (code precedes `//`), all 20 read by eye and confirmed: the executable statement is byte-identical before/after, only the referent tag after `//` changed. **All 1500 changed lines are comment/KDoc-only.** (Note: the literally-quoted pathspec `'store6-*/src'` is a no-op under this git's default glob-pathspec semantics — `*` triggers fnmatch full-path matching, and no path is literally named e.g. `store6-core/src`; the shell-expanded form `store6-*/src`, the same mechanism the sweep `grep` commands already use, and the explicit `:(glob)store6-*/src/**` form both return the expected 111/1500-line diff.) | +| `git diff 801b8e8...HEAD --stat -- '*/api'` and `-- store6-*/api` | Empty both ways. API dumps byte-identical to base. | + +### (d) Known residues (unchanged, protected content — recorded, not edited) + +- **Three protected-content FP string literals** (assertion/error-message text inside executable + test code, out of the doc-pass charter): `error("The AC-4 LocalOnly scenario must not fetch")` + (`MutationRestartWalkingTest.kt:100`), `error("The SQLDelight AC-4 LocalOnly scenario must not + fetch")` (`SqlDelightMutationRestartWalkingTest.kt:131`), and now formally the fourth of this + family, `"Committed KLib dump differs from the da72d908 T0.3 baseline: $dumpSha256"` + (`MutationApiSurfaceTest.kt:70`, ratified this task). +- **`landed.origin`** (`MutationAckPathTest.kt:71-72`) — a local variable name; `landed` is an + identifier, protected regardless of the word it happens to spell. +- **Internal shorthand inside test IDENTIFIERS** (function/class names, not comments or strings — + out of this doc-pass's charter, which covers comments and KDoc): e.g. + `builderForwardsEveryRuledConfigurationDoor`, `stablePublicEnums_exposeExactRuledValueSets` + (`MutationStoreBuilderTest.kt`/`MutationApiSurfaceTest.kt` test method names), and + `mutation023Banned`/`mutation023Violations` (`MutationApiSurfaceTest.kt` local vals). Renaming + identifiers is out of scope for a comment/KDoc cleanup pass and was never attempted. +- **The pre-existing `MutationJournalLincheckTest` flake**, change-independence proven twice: + SHA-256-identical `.class` files across the pristine and changed trees at Task 4 (`git stash`, + rebuild, hash all 16 `MutationJournalLincheckTest*`/`LincheckRecordFactory` class files — + identical both trees), and a second pristine-base reproduction at Task 7 (stash the entire + change set, rerun the single test against `8c84c16`, identical `LincheckAssertionError: The + execution has hung` failure in `inMemoryJournalTransactions_areLinearizable`). The diff touches + neither `MutationJournalLincheckTest.kt` nor `InMemoryMutationJournalStorage.kt`. This task's own + comment-only diff proof (mechanical hunk check, (c) above) plus the identical `api/` dumps + independently corroborate: nothing in this branch could have caused or changed this flake. +- **Final whole-branch review (post-report):** a polish commit on this branch applies the review's + Minor items (unsupported operator-rule claim in `StoreResultEquivalence.kt`, `ratified` in + `internal/FreshnessValidator.kt`, the `drainFailuresForInspection` category error and the + in-memory/codec-less term collision in `MutationEngine.kt`, the emptied `Iface tier` clause in + `StabilityProbe.kt`, three ungrammatical room hostTest comments, the undocumented + `FreshnessContext.status` sibling, a mid-sentence comment opening in `MutationStoreBuilderTest.kt`, + nine ragged-wrap reflows, six one-sentence KDoc collapses) plus four inventory corrections. Items + accepted as-is, not changed: the two same-fact-two-altitudes duplications, the devtools + freeze-status sibling wording, and the 43 boilerplate P3 action cells (recorded as a record gap + only — the underlying edits are correct). + +### (e) Unresolved rows + +- **`Unverifiable` rows: zero**, across every table in this inventory (Task 1 baseline, Task 3b, + Task 4, and Task 7's class totals each report `Unverifiable 0`). No contract claim in the entire + pass fell back to "keep meaning unchanged, mark Unverifiable" — every claim kept was verified + against code or a test, and every claim that could not be verified was deleted per the P3/P4 + rules rather than kept unresolved. +- **Recorded-unfixed audit findings** (Task 7, "recorded, not fixed" or outside the fix-class + vocabulary): + - `StoreResults.kt:16-27` — 11 factory functions, `Missing (minor)`, recorded not fixed (adding + per-factory KDoc would restate names/types already one Dokka hop away — judged Unnecessary + under this audit's own standard). + - `MutationJournalRecords.kt` — 82 of 92 durable-record properties remain undocumented, + `Missing`, fixed in part (see the corrected row above); each remaining property needs its + engine or storage write site read before a documentation claim can be made. + - `FakeStore.kt:271-273` (`close()`) — `Duplicated` (repeats the class KDoc verbatim in + meaning); not one of this audit's fix classes, no change. + - `StoreResults.kt:13,17-27` and `Store.kt:138,156` — column-width formatting outliers; not a + documentation-content class, no change (reflowing executable declarations would breach the + comment-only hunk boundary). + +### (f) Proof strength + +**Repository checks plus hunk review.** No mechanical token/AST trivia-equivalence prover exists in +this repository — the strongest claim this pass can make, and the only one made here, is that +`apiCheck` passes, all `api/` dumps are byte-identical, and every changed line in `store6-*/src` +was mechanically classified as comment/KDoc/blank or else read and judged by eye (20/1500 lines, +all confirmed clean). This is not a mechanically-proven claim of pure comment-only change; it is +repository checks (apiCheck, empty api/ diff) plus human/agent hunk review at every task boundary +and again at this final gate. + +### (g) ktlint/spotless no-op caveat + +`ktlintCheck` and `spotlessCheck` both report `BUILD SUCCESSFUL`, but neither exercises any +`store6-*` module: the root `build.gradle.kts` returns early for `store6-*` modules before wiring +ktlint/spotless tasks onto them, so both commands verify only the legacy modules (`cache`, `core`, +`multicast`, `rx2`, `store`). Formatting assurance for the `store6-*` changes in this branch rests +entirely on the per-task hunk-review discipline recorded throughout this inventory, plus the final +mechanical hunk check in (c) above — not on any automated formatter run. + diff --git a/docs/superpowers/plans/2026-08-15-store38-building-a-store6-data-layer-skill.md b/docs/superpowers/plans/2026-08-15-store38-building-a-store6-data-layer-skill.md new file mode 100644 index 000000000..e2c6408f5 --- /dev/null +++ b/docs/superpowers/plans/2026-08-15-store38-building-a-store6-data-layer-skill.md @@ -0,0 +1,398 @@ +# STORE-38: `building-a-store6-data-layer` Skill Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the second skill in the `store` plugin — `building-a-store6-data-layer` — for greenfield/rearchitect adopters who have no Store 4/5 code, built RED-first with a recorded baseline eval, and delivered as one PR to `matt-ramotar/Store6`. + +**Architecture:** One skill, not one per platform. `SKILL.md` carries key modeling, freshness selection, persistence wiring, lifecycle, and module placement; Android/KMP/iOS enter as trigger keywords and as four reference files (`room.md`, `sqldelight.md`, `compose.md`, `swift.md`). The eval mirrors `migrating-to-store6`: a fixture repo, a pressure prompt, a no-skill baseline run recorded verbatim, a with-skill run against pass criteria, and a refactor pass that closes observed gaps. + +**Tech Stack:** Claude Code plugin skills (markdown), subagent evals, Store 6 sources in `matt-ramotar/Store6` as ground truth (modules: `store6-core`, `store6-room`, `store6-sqldelight`, `store6-compose`, `store6-swift-dumps`, `store6-quickstart`, `store6-testing`). + +--- + +## Context (read before Task 0) + +- **Ticket:** [STORE-38](https://linear.app/wanderinginc/issue/STORE-38/store-plugin-building-a-store6-data-layer-skill) — "Store plugin — building-a-store6-data-layer skill". Greenfield counterpart to `migrating-to-store6`: same root failure (Store 6 is pre-alpha and absent from training data, so agents invent API spellings) but no legacy code, so the migration skill never triggers. Done when: baseline and with-skill runs recorded in `plugins/store/evals/`, skill and references shipped under `plugins/store/skills/`, every API claim stamped against a named commit, plugin version bumped. +- **Template:** [PR #46](https://github.com/matt-ramotar/Store6/pull/46) (merged as `6790606d`) shipped the plugin and the first skill. Mirror its structure, tone, and eval record exactly. Read these before writing anything: + - `plugins/store/skills/migrating-to-store6/SKILL.md` (63 lines: frontmatter → Overview → Ground truth → Workflow → Common mistakes → Red flags → References → stamp) + - `plugins/store/evals/migrating-to-store6/scenario.md`, `results.md`, `fixtures/UserRepository.kt` + - `plugins/store/README.md`, `plugins/store/.claude-plugin/plugin.json`, `/.claude-plugin/marketplace.json` +- **Method:** superpowers:writing-skills. Iron Law: **no skill without a failing test first.** The RED baseline runs before one line of SKILL.md exists. If the baseline passes without the skill, stop — the skill is not needed; report that on STORE-38 instead of shipping (same honest-exit clause STORE-43 carries). +- **Ground truth discipline:** every API spelling in the skill is verified against Store 6 **source** in this repo at the checkout commit, and SKILL.md ends with `Last verified against Store `main` @ `` (pre-`6.0.0-alpha01`)`. The docs pages listed per task are outline seeds. Where a docs page and source disagree, source wins. (The docs live in `~/src/matt-ramotar/store-docs`, which currently has uncommitted working-tree edits from a site restyle — one more reason source is the authority.) +- **Scope guards:** do not build the runnable `claude plugin eval` format (that is STORE-41), do not add a second migration fixture (STORE-44), do not touch `migrating-to-store6` content (any edit to an existing skill requires its own failing test first — routing between the two skills lives in the *new* skill's description and overview instead), and do not write mutations content (STORE-39, blocked on STORE-12). +- **Repos and gates:** all work in `matt-ramotar/Store6` on a branch → PR via `gh pr create --repo matt-ramotar/Store6`. Matt merges; opening the PR is a human gate. Linear: move STORE-38 to In Progress at start; comment with results when the PR opens. If the Linear MCP (`user-Linear`) is not available in the executing session, record the update as a note in the PR description instead. + +## File structure (end state) + +``` +plugins/store/ + README.md # MODIFY: add skill row + .claude-plugin/plugin.json # MODIFY: version 0.1.0 → 0.2.0, description + skills/ + migrating-to-store6/… # UNTOUCHED + building-a-store6-data-layer/ + SKILL.md # CREATE: core skill (target ≤ ~90 lines) + references/ + room.md # CREATE: store6-room adapter wiring + sqldelight.md # CREATE: store6-sqldelight adapter wiring + compose.md # CREATE: store6-compose consumption + swift.md # CREATE: Swift/SKIE consumption + evals/ + migrating-to-store6/… # UNTOUCHED + building-a-store6-data-layer/ + scenario.md # CREATE: pressures + pass criteria + results.md # CREATE: baseline, with-skill, refactor pass + fixtures/ + REQUIREMENTS.md # CREATE: product requirements sheet + AtlasApi.kt # CREATE: existing network client + Db.kt # CREATE: existing Room 3 database (v1, no sidecars) + ProfileViewModel.kt # CREATE: existing no-cache consumption +docs/superpowers/plans/2026-08-15-store38-…md # THIS FILE: commit with the PR +``` + +`.claude-plugin/marketplace.json` at the repo root registers the *plugin*, not individual skills — verify it needs no change (Task 6). + +--- + +## Task 0: Preflight — worktree, branch, stamp commit + +**Files:** none (setup only) + +- [ ] **Step 0.1:** Fetch and create a worktree on a fresh branch off `origin/main`: + +```bash +git -C ~/src/matt-ramotar/Store6 fetch origin main +git -C ~/src/matt-ramotar/Store6 worktree add /tmp/store38-skill -b plugins/store-data-layer-skill origin/main +cd /tmp/store38-skill +``` + +Branch name follows the repo's plugin precedent (`plugins/store-migration-skill`). If your session imposes its own branch-name template, use that instead and note it in the PR body. + +- [ ] **Step 0.2:** Record the stamp commit and confirm the plugin tree is present: + +```bash +git rev-parse --short=8 HEAD # expect 6790606d or newer; this sha goes in the SKILL.md stamp +ls plugins/store/skills/migrating-to-store6/SKILL.md .claude-plugin/marketplace.json +``` + +- [ ] **Step 0.3:** Copy this plan into the worktree (it ships with the PR, matching the committed plans in `docs/superpowers/plans/`): + +```bash +mkdir -p docs/superpowers/plans +cp ~/src/matt-ramotar/Store6/docs/superpowers/plans/2026-08-15-store38-building-a-store6-data-layer-skill.md docs/superpowers/plans/ +``` + +- [ ] **Step 0.4:** Move STORE-38 to In Progress in Linear (skip with a note if the MCP is unavailable). + +--- + +## Task 1: RED — fixture, pressure prompt, baseline run + +The baseline must run against a sandbox **outside** any Store checkout, with no skill present. Do not write any skill content before this task is complete. + +**Files:** +- Create (sandbox): `/tmp/store38-eval/baseline/{REQUIREMENTS.md,shared/src/commonMain/kotlin/com/atlas/api/AtlasApi.kt,shared/src/commonMain/kotlin/com/atlas/db/Db.kt,app/src/main/java/com/atlas/profile/ProfileViewModel.kt}` +- Create (scratch): `/tmp/store38-eval/baseline-notes.md` (verbatim failure log; feeds `results.md` in Task 5) + +- [ ] **Step 1.1: Write the fixture.** Four files. Room import spellings must be copied from this repo's `store6-room` module sources/sample — **do not guess Room 3 imports**; everything else below is complete. + +`REQUIREMENTS.md`: + +```markdown +# Atlas — user/session data layer requirements + +Kotlin Multiplatform app: `shared/` (KMP), `app/` (Android, Compose), `iosApp/` (Swift via SKIE). +Build a shared data layer for user profiles and the auth session. `store6-core` and `store6-room` +are on the classpath; packages live under `org.mobilenativefoundation.store6.*`. + +1. A profile, once loaded, is visible offline on next launch (persisted in the existing Room db). +2. Pull-to-refresh on the profile screen must hit the server. +3. The session is trusted for at most 5 minutes; after that, reads must revalidate. +4. Fetch failures retry 3 times with backoff. +5. Cap the cache at 50 users. +6. Sign-out removes all locally persisted user data immediately. +7. A push notification marks one user's profile stale without deleting it. +8. iOS consumes the same shared store from Swift. +``` + +`AtlasApi.kt`: + +```kotlin +package com.atlas.api + +class UserDto(val id: String, val name: String, val email: String) +class SessionDto(val token: String, val userId: String, val expiresAtEpochMillis: Long) + +class AtlasApi { + suspend fun getUser(id: String): UserDto = TODO("network call") + suspend fun getSession(): SessionDto = TODO("network call") +} +``` + +`Db.kt` (Room 3 — copy exact import spellings from `store6-room`; database is v1 with **no** Store6 sidecar tables, so adding them is part of the task): + +```kotlin +package com.atlas.db + +// Room 3 (androidx.room3) imports: copy exact spellings from the store6-room sample. + +@Entity(tableName = "users") +class UserEntity(@PrimaryKey val id: String, val name: String, val email: String) + +@Dao +interface UserDao { + @Query("SELECT * FROM users WHERE id = ?") fun user(id: String): Flow + @Upsert suspend fun upsert(row: UserEntity) + @Query("DELETE FROM users WHERE id = ?") suspend fun delete(id: String) + @Query("DELETE FROM users") suspend fun deleteAll() +} + +@Database(entities = [UserEntity::class], version = 1) +abstract class AppDatabase : RoomDatabase() { + abstract fun userDao(): UserDao +} +``` + +`ProfileViewModel.kt`: + +```kotlin +package com.atlas.profile + +// androidx.lifecycle and kotlinx.coroutines imports elided. + +sealed interface ProfileUiState { + data object Loading : ProfileUiState + class Loaded(val user: com.atlas.api.UserDto) : ProfileUiState + class Failed(val error: Throwable) : ProfileUiState +} + +class ProfileViewModel(private val api: com.atlas.api.AtlasApi) : ViewModel() { + val state = MutableStateFlow(ProfileUiState.Loading) + + fun load(userId: String) { + viewModelScope.launch { + state.value = try { + ProfileUiState.Loaded(api.getUser(userId)) + } catch (t: Throwable) { + ProfileUiState.Failed(t) + } + } + } + // TODO: offline cache, pull-to-refresh, sign-out wipe, push-driven staleness +} +``` + +- [ ] **Step 1.2: Write the baseline prompt.** Save to `/tmp/store38-eval/baseline-prompt.md`. Three pressures (authority, time, isolation) plus a design bait (TTL/retry/cap asks that Store 6 deliberately has no knobs for). The prompt is parameterized on ``: every run replaces both occurrences with that run's sandbox path — Task 1 uses `/tmp/store38-eval/baseline`; Task 5 substitutes its own sandbox. Use verbatim: + +```markdown +You are working in the repo at . This is a Kotlin Multiplatform app +(Android + iOS). Read REQUIREMENTS.md, then implement the shared user/session data layer with +Store 6. + +Facts about your environment: +- `store6-core` and `store6-room` are on the classpath. Packages live under + `org.mobilenativefoundation.store6.*`. This sandbox is offline: no web access, and the Store + repository is not on this machine. +- The tech lead, who used Store 5 for years, says: "Store 6 is the same shape as Store 5 with a + tidier builder — StoreBuilder, Fetcher.of, a Validator for the TTL. Don't overthink it." +- Your teammate is blocked until you produce these files, and the demo is Monday morning. + Produce working code now rather than asking questions. +- Work only inside . Do not read any other directory on this machine. + +Deliverables: +1. A new file (or files) in the shared module with the keys, store(s), and persistence wiring — + you choose the source set and package and say why. +2. Any edits to Db.kt needed for offline persistence. +3. Updated ProfileViewModel.kt consumption. +4. NOTES.md — for every Store 6 API you used, your confidence it exists with that exact spelling + and how you would verify it; where the store definition should live and where the database + instance comes from on each platform; and how iOS consumes this data layer. +``` + +- [ ] **Step 1.3: Run the baseline.** Dispatch one fresh general-purpose subagent whose entire prompt is the file above (no mention of skills, plugins, or this plan). Capture all four deliverables. + +- [ ] **Step 1.4: Record the failure log.** In `/tmp/store38-eval/baseline-notes.md`, record verbatim: every invented or Store 5 spelling (predicted: `StoreBuilder.from`, `Fetcher.of`, `SourceOfTruth.of`, `Validator`, `.cachePolicy(...)`/TTL knobs, retry wrappers around the store, bare `String` keys, wrong package roots, invented `store6-room` wiring), every requirement mishandled (5-minute session bound as a builder TTL instead of per-read `MaxAge`; "cap at 50 users" accepted as a data-cap knob; sign-out vs push-staleness collapsed into one operation; no `close()` owner; placement left implicit or platform code in common source sets), and the agent's own confidence statements from NOTES.md. **Contamination check:** confirm from the run's tool activity and NOTES.md that the agent did not read any Store checkout on this machine; if it did, the RED run is invalid — tighten the confinement instruction and re-run in a fresh sandbox. + +- [ ] **Step 1.5: GATE.** If the baseline produces a correct Store 6 data layer (measured against Task 4's pass criteria), **stop the plan**: comment the runs on STORE-38, recommend closing as not-needed, and do not write the skill. Otherwise continue. + +- [ ] **Step 1.6: Commit the plan file** (only the plan so far, from the worktree): + +```bash +git add docs/superpowers/plans/2026-08-15-store38-building-a-store6-data-layer-skill.md +git commit -m "docs(plans): add STORE-38 data-layer skill plan" +``` + +--- + +## Task 2: Ground-truth ledger — verify before writing + +Every claim below goes into the skill only after reading the named source in this worktree. Keep a scratch ledger (`/tmp/store38-eval/ledger.md`) mapping claim → source file:line, so the Task 7 accuracy pass is mechanical. + +**Files:** read-only against the worktree + +- [ ] **Step 2.1: Core claims** (most already verified for `migrating-to-store6` at `c67a94ed` — re-verify at your checkout): + - Builder DSL `store { fetcher { … }; persistence(…); bookkeeper(…) }`; fetcher block required (`IllegalArgumentException` without) → `store6-core` builder sources; runnable shape in `store6-quickstart`. + - `StoreKey` = `namespace: StoreNamespace` + `canonicalId(): String`; canonical id is identity/dedup; namespace is the bulk-operation unit → core key sources. + - Exactly five `Freshness` policies (`CachedOrFetch` default, `MaxAge(notOlderThan)`, `MustBeFresh`, `StaleIfError`, `LocalOnly`) → core freshness sources. + - Exactly four `StoreResult` kinds (`Loading`, `Data(value, origin, age, isStale, refreshing)`, `Revalidated(age)`, `Error(error, servedStale)`); `stream` never throws retrieval failures; `get` returns or throws `StoreException` → core result sources. + - `SourceOfTruth` five operations (`reader/write/delete/deleteNamespace/deleteAll`), installed via `persistence(...)`; `@ExperimentalStoreApi` to use, plus `DelicateStoreApi` to implement → `store6-core` seam package. + - `invalidate*` marks stale and keeps; `clear*` destroys; maintenance ops suspend and can throw `StoreException`; `close()` is a plain function → core store interface. + - Engine: per-key single-flight dedup, stale-while-revalidate, durable invalidation, `maxIdleKeys` default 128 (idle-engine bound, **not** a data-lifetime or row-count cap), zero retries/backoff, no TTL/cache-policy knob → core config + conformance tests named in the docs "Important defaults" page (e.g. `fetcherFailure_isNotRetried_zeroConfig`, `defaultFreshness_isCachedOrFetch_zeroConfig`). + - Module placement in a KMP project: keys, models, and the `store { }` definition are common code (`commonMain` of the shared module); platform-specific inputs (the Room database instance / SQLDelight driver) are constructed per platform and injected into the shared wiring; iOS consumes the same shared store through the shared framework → verify against the `store6-quickstart` module layout and the KMP source-set structure of `store6-room`, `store6-sqldelight`, and `store6-compose` (which source sets each publishes/compiles for). If Room 3's KMP setup verifies to a different construction pattern (e.g. a common database class with platform-provided builders), the ledger finding — not this plan's wording — dictates the final phrasing of the placement claim in SKILL.md and in pass criterion 12 (source wins). +- [ ] **Step 2.2: Room claims** → `store6-room` sources and sample: `RoomSourceOfTruth(database, rowReader, rowWriter, rowDeleter, namespaceDeleter, allDeleter)`, `RoomBookkeeper`, `Store6BookkeepingEntity`, `Store6WatermarkEntity`, `Store6BookkeeperDao`, `Store6RoomSchema.createTables(connection)`; sidecar tables `store6_bookkeeping` + `store6_watermarks`; user tables untouched; one version bump + one migration; Room 3 (`androidx.room3`, Kotlin ≥ 2.3, AGP ≥ 8.10 on Android); source-set-level opt-in covers generated DAO code. +- [ ] **Step 2.3: SQLDelight claims** → `store6-sqldelight` sources: `SqlDelightSourceOfTruth(driver, transacter, readQuery, writeRow, deleteRow, deleteNamespaceRows, deleteAllRows)`, `SqlDelightBookkeeper(driver, db)`; four self-created `store6_meta*` sidecar tables; the three boundary rules (write/read round trip; one `SqlDriver` for everything; `withTransaction` is synchronous — suspension throws `IllegalStateException` and rolls back). +- [ ] **Step 2.4: Compose claims** → `store6-compose` sources: `Store.collectAsState(key, freshness)`, `Flow.collectAsStoreState(initial)`, `collectAsStateWithLifecycle`/`collectAsStoreStateWithLifecycle`, `skipEqualData()`, `storeResultMutationPolicy()`; skipping is structural on `Data` (age excluded), `Loading`/`Revalidated`/`Error` always pass; stability conf snippet `stability/store6-stability.conf` covering `org.mobilenativefoundation.store6.core.*` and `.core.seam.*`. +- [ ] **Step 2.5: Swift claims** → committed dumps in `store6-swift-dumps`: SKIE `onEnum(of:)` exhaustive case sets (`StoreResult`: data/error/loading/revalidated; `Freshness`: five; `StoreError`: six, frozen for 6.x; `FetcherResult`: four); suspend ops exported as completion-handler (ObjC) and `async throws` (SKIE); `stream` bridges to `AsyncSequence` (task-scoped iteration); `close()` synchronous; `Duration`/`Long` flatten to `int64_t` with different meanings (`Duration` tagged raw representation vs `writtenAtEpochMillis` epoch millis); ObjC lane converts `CancellationException` to `NSError`, other uncaught Kotlin exceptions fatal. +- [ ] **Step 2.6:** Cross-check outline seeds (secondary): store-docs pages `key-design.mdx`, `important-defaults.mdx`, `concepts/freshness.mdx`, `concepts/memory-and-lifecycle.mdx`, `guides/persistence.mdx`, `quickstart.mdx`, `room.mdx`, `sqldelight.mdx`, `compose.mdx`, `guides/swift.mdx`. Source wins on any disagreement; note disagreements in the ledger. + +--- + +## Task 3: GREEN — write SKILL.md + +**Files:** +- Create: `plugins/store/skills/building-a-store6-data-layer/SKILL.md` + +- [ ] **Step 3.1: Frontmatter.** Name `building-a-store6-data-layer`. Description is triggers-only (no workflow summary), third person, < 500 chars. Draft (adjust against baseline observations): + +```yaml +--- +name: building-a-store6-data-layer +description: Use when adding Store 6 (org.mobilenativefoundation.store6) to an app or KMP module with no Store 4/5 code to migrate — designing a data layer or offline cache, modeling StoreKeys, choosing Freshness, wiring Room or SQLDelight persistence, consuming a store from Compose or Swift, or unsure whether a Store 6 API exists. For code that already uses Store 4/5, use migrating-to-store6. +--- +``` + +- [ ] **Step 3.2: Body.** Mirror the migration skill's shape and its "never invent an API" stance. Sections, in order: + 1. **Overview** — Store 6 is pre-alpha and absent from training data; if a spelling is not in this skill, its references, or verifiable source, assume it does not exist and say so. Route: legacy Store 4/5 code present → `migrating-to-store6`. + 2. **Ground truth** — bullets from Task 2.1 (packages/publishing, builder DSL, keys, freshness, results, persistence + adapters, maintenance + lifecycle, engine behavior you do not build: dedup, SWR, durable invalidation, `maxIdleKeys`; no retries, no TTL knob). + 3. **Workflow: design decisions in order** — (1) model keys: one namespace per record type, canonical id contains everything that changes the returned bytes; (2) write the fetcher and put retry/backoff/fallback policy inside it; (3) choose persistence: default in-memory / `store6-room` / `store6-sqldelight` / custom seam validated with the `store6-testing` contract kit; (4) choose per-read `Freshness` per call site (needs-based table: offline-first read → default; bounded trust → `MaxAge`; user-forced refresh → `MustBeFresh`; flaky network tolerance → `StaleIfError`; never fetch → `LocalOnly`); (5) place the code: keys, models, and the `store { }` definition in the shared module's `commonMain`, platform-constructed inputs (database instance, driver) injected from platform source sets, one store instance shared by Android and iOS; (6) wire consumption per platform (references); (7) assign a lifecycle owner that calls `close()`; (8) wire maintenance: stale-not-wrong → `invalidate*`, wrong-to-show → `clear*`. + 4. **Common mistakes** — table built from the Task 1 baseline log (each row = an observed failure), expected rows: TTL/`Validator`/`cachePolicy` on the builder → per-read `MaxAge` + durable invalidation; retry wrapper around the store → retries in the fetcher, engine retries zero times; "cap cache at 50 users" → no row-count cap exists; `maxIdleKeys` bounds idle engines, say so honestly; bare `String` key → `StoreKey`; sign-out via `invalidateAll` → `clearAll()` (and push-staleness via `invalidate(key)`, not delete); missing opt-ins; missing `close()`. + 5. **Red flags** — spellings that mean stop and open a reference (`StoreBuilder`, `Fetcher.of`, `SourceOfTruth.of`, `Validator`, `.cachePolicy`, `.ttl`, retry/backoff arguments on the builder, a bare-`String` key, any `store6-room`/`store6-sqldelight`/compose/Swift name not in the references). + 6. **References** — the four files with one-line whens. + 7. **Stamp** — `Last verified against Store `main` @ `` (pre-`6.0.0-alpha01`). Re-verify spellings against the release you target.` + +Target ≤ ~90 lines. Address only failures the baseline actually showed plus the ground truth needed to counter them — no hypothetical content. + +--- + +## Task 4: GREEN — references and eval scaffold + +**Files:** +- Create: `plugins/store/skills/building-a-store6-data-layer/references/{room.md,sqldelight.md,compose.md,swift.md}` +- Create: `plugins/store/evals/building-a-store6-data-layer/scenario.md` +- Create: `plugins/store/evals/building-a-store6-data-layer/fixtures/` (copy the four Task 1 fixture files verbatim) + +- [ ] **Step 4.1: `references/room.md`** — from Task 2.2: dependency/plugin block (Room 3 caveats: `room3 { schemaDirectory(...) }` extension name, Kotlin ≥ 2.3, AGP ≥ 8.10), the three-declaration database diff (two sidecar entities + `store6BookkeeperDao()` accessor + version bump), the migration calling `Store6RoomSchema.createTables`, full `RoomSourceOfTruth` + `RoomBookkeeper` wiring into `store { }`, the schema claim (sidecars only; user tables untouched), and the `store6-testing` contract-kit pointer for custom seams. +- [ ] **Step 4.2: `references/sqldelight.md`** — from Task 2.3: adapter construction with generated queries, the four self-created `store6_meta*` tables (no `.sq` changes), the three boundary rules verbatim-faithful (round trip, one driver, synchronous `withTransaction` — suspension throws), and the one-logical-store-per-database note. +- [ ] **Step 4.3: `references/compose.md`** — from Task 2.4: entry points, four-kind handling in UI state (never merge kinds; `Revalidated` is a lifecycle signal), skip-equal-`Data` discipline, the stability conf snippet and what it changes, and when to collect the Flow instead of a State (event-shaped `Revalidated`/`Error`). +- [ ] **Step 4.4: `references/swift.md`** — from Task 2.5: `onEnum(of:)` switches with the exact case-set table, suspend → `async throws` (SKIE) vs completion-handler (ObjC), `stream` as `AsyncSequence` with task-scoped iteration, the `Duration`/`int64_t` trap table, and the exception-boundary warning. +- [ ] **Step 4.5: `evals/building-a-store6-data-layer/scenario.md`** — mirror the migration scenario's shape: what the fixture covers, setup (copy fixture to sandbox; with-skill run also copies the skill directory and introduces it only by its description), the three pressures + design bait, then **pass criteria (with skill)**: + 1. Keys implement `StoreKey` (`namespace`, `canonicalId()`); no bare `String` keys; user and session get distinct namespaces. + 2. Builder is the `store { fetcher { … } }` DSL with `persistence(...)`/`bookkeeper(...)`; no `StoreBuilder`, `Fetcher.of`, `SourceOfTruth.of`, `Validator`, cache-policy/TTL knob. + 3. Imports under `org.mobilenativefoundation.store6.core` (adapter path; `.core.seam` only if a custom seam is implemented). + 4. Room: sidecar entities + DAO accessor added, version bumped, migration calls `Store6RoomSchema.createTables`, wiring via `RoomSourceOfTruth`/`RoomBookkeeper`; user table schema untouched. + 5. Freshness per read site: profile screen default `CachedOrFetch`; session bound as per-read `MaxAge(5.minutes)`; pull-to-refresh `MustBeFresh` — not builder-level TTL. + 6. Retry lives inside the fetcher with an explicit note that the engine retries zero times. + 7. "Cap at 50 users" is answered honestly: no data-cap knob; `maxIdleKeys` bounds idle engines — stated, not faked. + 8. Sign-out uses `clearAll()`; push-staleness uses `invalidate(key)`; the wrong-vs-old decision test appears. + 9. Consumption handles all four `StoreResult` kinds including `Revalidated` (plain collect or `store6-compose` entry points; no invented compose API). + 10. `@OptIn(ExperimentalStoreApi::class)` where persistence/adapters are used; store has a `close()` owner or an explicit ownership note. + 11. No invented API, no invented dependency coordinates; NOTES.md sources spellings to the skill. + 12. Placement: keys and the `store { }` definition land in the shared module's common source set; the platform-constructed input (the Room database instance) is injected from platform code rather than built in common code; NOTES.md states that iOS consumes the same shared store. Implemented or explicitly stated — not left implicit. +- [ ] **Step 4.6: Copy fixtures** into `evals/building-a-store6-data-layer/fixtures/` unchanged. +- [ ] **Step 4.7: Commit:** + +```bash +git add plugins/store/skills/building-a-store6-data-layer plugins/store/evals/building-a-store6-data-layer +git commit -m "feat(plugins): draft building-a-store6-data-layer skill and eval scaffold (STORE-38)" +``` + +--- + +## Task 5: REFACTOR — with-skill run, close gaps, record results + +**Files:** +- Create (sandbox): `/tmp/store38-eval/with-skill/` (fixture + skill directory) +- Create: `plugins/store/evals/building-a-store6-data-layer/results.md` +- Modify: skill files, wherever the run surfaced gaps + +- [ ] **Step 5.1:** Build the with-skill sandbox at `/tmp/store38-eval/with-skill/`: copy the four fixture files, then copy `plugins/store/skills/building-a-store6-data-layer/` into the sandbox at `skills/building-a-store6-data-layer/`. +- [ ] **Step 5.2:** Dispatch a fresh subagent with the **same prompt** as Task 1 — with both `` occurrences set to this run's sandbox (`/tmp/store38-eval/with-skill`; re-runs use fresh sandboxes `/tmp/store38-eval/with-skill-2`, `-3`, … so the confinement line stays true each iteration) — plus only this line (skill introduced by description, as installed): + +```markdown +This repo has a skill installed at skills/building-a-store6-data-layer/ — "". Use it if relevant. +``` + +- [ ] **Step 5.3:** Grade the deliverables against every Task 4.5 pass criterion. Any criterion failed, or any place the agent had to guess (check its NOTES.md): fix the skill or reference, then **re-run Step 5.2 in a fresh sandbox, rebuilt per Step 5.1 with the current skill files** (each fix must be re-copied into the new sandbox), until all criteria pass. Record each closed gap. +- [ ] **Step 5.4: Retrieval probes for the two references the fixture does not exercise.** `sqldelight.md` and `swift.md` must not ship unread. In a fresh sandbox containing only the skill directory (confine the agent with the same "work only inside this sandbox" instruction), ask a fresh subagent one task-shaped question per file — "wire these generated SQLDelight queries into a Store 6 store" and "consume a shared Store 6 store's stream and results from Swift" — and confirm each answer comes from the reference with no invented spellings. These are lightweight retrieval checks, not full evals; close any gap they surface. +- [ ] **Step 5.5:** Write `results.md` in the migration record's format: `## Baseline (no skill): fails` with verbatim quotes from Task 1; `## With skill: pass` with which criteria passed and the agent's own sourcing statements; `## Retrieval checks` one line each for the Step 5.4 probes; `## Refactor pass` listing gaps closed after runs; end with future eval variants worth adding (candidate: an iOS-first fixture exercising `swift.md`, and a SQLDelight-instead-of-Room variant — do not build them now). +- [ ] **Step 5.6:** Commit: + +```bash +git add plugins/store +git commit -m "test(plugins): record data-layer skill eval runs (STORE-38)" +``` + +--- + +## Task 6: Registration — README, plugin.json, marketplace check + +**Files:** +- Modify: `plugins/store/README.md` (Skills list) +- Modify: `plugins/store/.claude-plugin/plugin.json` +- Verify only: `/.claude-plugin/marketplace.json` + +- [ ] **Step 6.1:** Add to README's Skills list: `- `building-a-store6-data-layer`: designing a new Store 6 data layer (keys, freshness, persistence, platform consumption) when there is no Store 4/5 code to migrate.` +- [ ] **Step 6.2:** `plugin.json`: bump `version` `0.1.0` → `0.2.0`; update `description` to cover both skills (e.g. "Skills for applications using Store (org.mobilenativefoundation.store): building a Store 6 data layer and migrating from Store 4/5."); extend `keywords` with `"data-layer"`, `"offline"`, `"android"`, `"ios"`, `"compose"`. +- [ ] **Step 6.3:** Validate and confirm the marketplace needs no change (it registers plugins, not skills): + +```bash +jq . plugins/store/.claude-plugin/plugin.json >/dev/null && echo PLUGIN-JSON-OK +jq -r '.plugins[].source' .claude-plugin/marketplace.json # expect ./plugins/store, unchanged +``` + +- [ ] **Step 6.4:** Commit: + +```bash +git add plugins/store/README.md plugins/store/.claude-plugin/plugin.json +git commit -m "chore(plugins): register data-layer skill, bump store plugin to 0.2.0 (STORE-38)" +``` + +--- + +## Task 7: Three-pass review and stamp + +**Files:** modify skill files only if a pass fails + +- [ ] **Step 7.1: Accuracy pass.** Walk SKILL.md and all four references; every API spelling and behavioral claim must trace to a ledger entry (Task 2) pointing at source in this worktree. Anything untraceable gets verified now or deleted. +- [ ] **Step 7.2: Warrant pass.** Every "must/never/exactly" claim is evidence-backed (source or conformance test), not vibes. The stamp names the Task 0 sha. +- [ ] **Step 7.3: Reader-utility pass.** SKILL.md ≤ ~90 lines; frontmatter ≤ 1024 chars total with a triggers-only description (target < 500 chars); references are shallow tables/snippets, not essays; relative links between SKILL.md and references resolve (`ls` each target); no internal shorthand (ticket IDs, session names) inside skill content — commit messages may reference STORE-38, skill content may not. +- [ ] **Step 7.4:** Amend/commit any fixes: `git commit -am "fix(plugins): data-layer skill review pass (STORE-38)"` (skip if clean). + +--- + +## Task 8: PR and closeout + +- [ ] **Step 8.1:** Push and open the PR against `matt-ramotar/Store6` (the `--repo` flag is mandatory): + +```bash +git push -u origin plugins/store-data-layer-skill +gh pr create --repo matt-ramotar/Store6 \ + --title "feat(plugins): add building-a-store6-data-layer skill" \ + --body-file /tmp/store38-pr-body.md +``` + +PR body follows `pull_request_template.md` and PR #46's precedent: what the skill is and why (greenfield agents invent the pre-alpha API), the RED-first test plan with both runs summarized from `results.md`, the honest limits (not compiled against a consumer build; nothing published before `6.0.0-alpha01`), checklist marked truthfully, and a Follow-ups line pointing at STORE-41/STORE-43/STORE-44 by URL. + +- [ ] **Step 8.2:** Linear: comment on STORE-38 with the PR URL, the stamp sha, and one-line eval numbers (criteria passed N/N; gaps closed). Leave the issue In Progress — **merging is Matt's gate**; move to Done only after merge (or note the state if the MCP is unavailable). +- [ ] **Step 8.3:** Stop. Do not start STORE-39/40/41/43/44 in this branch or session. + +--- + +## Failure modes for the implementer (read once) + +- Writing any skill prose before the Task 1 baseline exists violates the Iron Law — delete it and start over. +- Copying `migrating-to-store6` tables into the new skill wholesale: the audiences differ; this skill teaches *design decisions*, the migration skill teaches *translation*. Cross-link, don't duplicate. +- Trusting the docs pages over source: docs are seeds; source at the stamp sha is the contract. +- Letting the fixture leak the answer (e.g. pre-adding sidecar entities or naming `RoomSourceOfTruth` in REQUIREMENTS.md): the fixture states needs, never Store 6 spellings. +- Grading the with-skill run generously: a criterion "mostly met" is a gap; fix and re-run in a fresh sandbox. diff --git a/gradle.properties b/gradle.properties index df28b34f9..98d2b1e6e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,16 +1,15 @@ # don't use jetifier, all deps are in androidX already android.enableJetifier=true android.useAndroidX=true org.gradle.caching=true -org.gradle.configureondemand=false -org.gradle.configuration-cache=true +org.gradle.configureondemand=true +org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled # https://github.com/Kotlin/dokka/issues/1405 org.gradle.jvmargs=-XX:MaxMetaspaceSize=2G # POM file GROUP=org.mobilenativefoundation.store -# Project version lives in gradle/libs.versions.toml (store) and is applied to -# project.version by KotlinMultiplatformConventionPlugin. +VERSION_NAME=5.1.0-SNAPSHOT POM_PACKAGING=pom POM_DESCRIPTION = Store5 is a Kotlin Multiplatform network-resilient repository layer @@ -25,5 +24,7 @@ POM_DEVELOPER_ID=dropbox POM_DEVELOPER_NAME=Dropbox kotlinx.atomicfu.enableJvmIrTransformation=false kotlinx.atomicfu.enableJsIrTransformation=false +kotlin.js.compiler=ir -org.jetbrains.compose.experimental.uikit.enabled=true \ No newline at end of file +org.jetbrains.compose.experimental.uikit.enabled=true +kotlin.mpp.androidGradlePluginCompatibility.nowarn=true diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties deleted file mode 100644 index cfd299ab0..000000000 --- a/gradle/gradle-daemon-jvm.properties +++ /dev/null @@ -1,13 +0,0 @@ -#This file is generated by updateDaemonJvm -toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/584d2f01a3c6e59ebb9478a182f5f714/redirect -toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/7352c4c0c11b2db21fdd7541204de287/redirect -toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/584d2f01a3c6e59ebb9478a182f5f714/redirect -toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/7352c4c0c11b2db21fdd7541204de287/redirect -toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/12ed6bcbab330f7afa37d16220b272a3/redirect -toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/a7e1d8e6e800a81047d4aec26156ef5c/redirect -toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/584d2f01a3c6e59ebb9478a182f5f714/redirect -toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/7352c4c0c11b2db21fdd7541204de287/redirect -toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/3676ee7aa5095d7f22645eb0f22ca159/redirect -toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/fc98f2d434c5796fe6ec02f0f22957b3/redirect -toolchainVendor=AZUL -toolchainVersion=17 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 79b548680..7d1691990 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,53 +1,79 @@ [versions] -#noinspection UnusedVersionCatalogEntry androidMinSdk = "24" -#noinspection UnusedVersionCatalogEntry +# room3/androidx.sqlite 2.7 AARs declare minCompileSdk=34; the repo's Android floor must not advertise below it. androidCompileSdk = "34" -androidGradlePlugin = "9.3.0" -#noinspection UnusedVersionCatalogEntry -androidTargetSdk = "34" -atomicFu = "0.33.0" -baseKotlin = "2.3.21" +# room3 Gradle plugin floor is AGP 8.10.0. +androidGradlePlugin = "8.10.0" +androidTargetSdk = "33" +atomicFu = "0.24.0" +# Room 3 native KLIBs are ABI 2.3.0; any 2.3.x compiler consumes them. +baseKotlin = "2.3.20" dokkaGradlePlugin = "2.2.0" -ktlintGradle = "14.2.0" -mavenPublishPlugin = "0.37.0" -spotlessPluginGradle = "8.8.0" +ktlintGradle = "12.1.0" +jacocoGradlePlugin = "0.8.12" +mavenPublishPlugin = "0.34.0" +moleculeGradlePlugin = "1.2.1" +# androidx.paging KMP line (paging-common/testing); 3.5.1 = latest stable 2026-08-12. No Intel targets since 3.4.0-rc01. +paging = "3.5.1" +spotlessPluginGradle = "6.4.1" +skie = "0.10.13" junit = "4.13.2" -#noinspection UnusedVersionCatalogEntry -jvmToolchain = "17" -#noinspection UnusedVersionCatalogEntry -jvmCompat = "11" -kotlinxCoroutines = "1.11.0" -kotlinxSerialization = "1.11.0" -kermit = "2.1.0" -testCore = "1.7.0" -kmmBridge = "1.2.1" -#noinspection UnusedVersionCatalogEntry -ktlint = "1.8.0" -kover = "0.9.9" -#noinspection UnusedVersionCatalogEntry -store = "5.1.0-SNAPSHOT" -truth = "1.4.5" -turbine = "1.2.1" -binary-compatibility-validator = "0.18.1" +kotlinxCoroutines = "1.8.1" +kotlinxSerialization = "1.6.3" +kermit = "2.0.5" +testCore = "1.6.1" +kmmBridge = "0.5.7" +ktlint = "0.39.0" +kover = "0.9.0-RC" +store = "5.1.0-alpha10" +truth = "1.1.3" +turbine = "1.2.0" +binary-compatibility-validator = "0.17.0" +# PIN: KSP2 standalone versioning (no Kotlin lockstep); verify Kotlin support range on every Kotlin bump. +ksp = "2.3.10" +# PIN: SQLDelight 2.1.0 remains intentionally pinned; it was built with older Kotlin. +sqldelight = "2.1.0" +# room3: new coordinates/packages/type identity; native KLIBs require Kotlin >=2.3 + AGP >=8.10 +# (falsified-claim record in decisions/store6-room-packaging.md). +room3 = "3.0.0" +# Exactly the androidx.sqlite version in room3 3.0.0's POM. +androidxSqlite = "2.7.0" +# PIN: Compose 1.8.2 remains intentionally pinned for 012. +jetbrainsCompose = "1.8.2" +# PIN: Lifecycle 2.9.1 remains intentionally pinned for 012. +jetbrainsAndroidxLifecycle = "2.9.1" +# PIN: kotlinx-benchmark for the unpublished store6-benchmarks harness. 0.4.17 is the newest +# stable release at execution (repo is on Kotlin 2.3.20; the resolution probe below is the +# compatibility ground truth). Unpublished-module dep: never appears in any public signature +# (C-4 untouched). +kotlinxBenchmark = "0.4.17" +# TEST-3 JVM-only model checking; 2.39 uses dynamic instrumentation and needs no module opens. +lincheck = "2.39" [libraries] android-gradle-plugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } +androidx-paging-common = { module = "androidx.paging:paging-common", version.ref = "paging" } +androidx-paging-testing = { module = "androidx.paging:paging-testing", version.ref = "paging" } kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "baseKotlin" } +kotlin-serialization-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-serialization", version.ref = "baseKotlin" } dokka-gradle-plugin = { group = "org.jetbrains.dokka", name = "dokka-gradle-plugin", version.ref = "dokkaGradlePlugin" } +ktlint-gradle-plugin = { group = "org.jlleitschuh.gradle", name = "ktlint-gradle", version.ref = "ktlintGradle" } +jacoco-gradle-plugin = { group = "org.jacoco", name = "org.jacoco.core", version.ref = "jacocoGradlePlugin" } maven-publish-plugin = { group = "com.vanniktech", name = "gradle-maven-publish-plugin", version.ref = "mavenPublishPlugin" } +kover-gradle-plugin = {group = "org.jetbrains.kotlinx", name = "kover-gradle-plugin", version.ref = "kover"} atomic-fu-gradle-plugin = { group = "org.jetbrains.kotlinx", name = "atomicfu-gradle-plugin", version.ref = "atomicFu" } -kmmBridge-gradle-plugin = { group = "co.touchlab.kmmbridge.github", name = "co.touchlab.kmmbridge.github.gradle.plugin", version.ref = "kmmBridge" } -ktlint-gradle-plugin = { module = "org.jlleitschuh.gradle.ktlint:org.jlleitschuh.gradle.ktlint.gradle.plugin", version.ref = "ktlintGradle" } -spotless-gradle-plugin = { module = "com.diffplug.spotless:com.diffplug.spotless.gradle.plugin", version.ref = "spotlessPluginGradle" } +kmmBridge-gradle-plugin = { group = "co.touchlab.kmmbridge", name = "co.touchlab.kmmbridge.gradle.plugin", version.ref = "kmmBridge" } kotlinx-atomic-fu = { group = "org.jetbrains.kotlinx", name = "atomicfu", version.ref = "atomicFu" } kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "baseKotlin" } kotlinx-serialization-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-core", version.ref = "kotlinxSerialization" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-rx2 = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-rx2", version.ref = "kotlinxCoroutines" } +molecule-gradle-plugin = { module = "app.cash.molecule:molecule-gradle-plugin", version.ref = "moleculeGradlePlugin" } +molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "moleculeGradlePlugin" } rxjava = { group = "io.reactivex.rxjava2", name = "rxjava", version = "2.2.21" } androidx-test-core = { group = "androidx.test", name = "core", version.ref = "testCore" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } @@ -55,18 +81,31 @@ junit = { group = "junit", name = "junit", version.ref = "junit" } google-truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } touchlab-kermit = { group = "co.touchlab", name = "kermit", version.ref = "kermit" } turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } +binary-compatibility-validator = {module = "org.jetbrains.kotlinx:binary-compatibility-validator", version.ref = "binary-compatibility-validator"} +sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" } +sqldelight-coroutines-extensions = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" } +sqldelight-primitive-adapters = { module = "app.cash.sqldelight:primitive-adapters", version.ref = "sqldelight" } +sqldelight-native-driver = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" } +sqldelight-android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } +sqldelight-sqlite-driver = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" } +sqldelight-web-worker-driver = { module = "app.cash.sqldelight:web-worker-driver", version.ref = "sqldelight" } +room3-runtime = { module = "androidx.room3:room3-runtime", version.ref = "room3" } +room3-compiler = { module = "androidx.room3:room3-compiler", version.ref = "room3" } +room3-testing = { module = "androidx.room3:room3-testing", version.ref = "room3" } +androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "androidxSqlite" } +jetbrains-compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "jetbrainsCompose" } +jetbrains-lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "jetbrainsAndroidxLifecycle" } +kotlinx-benchmark-runtime = { module = "org.jetbrains.kotlinx:kotlinx-benchmark-runtime", version.ref = "kotlinxBenchmark" } +lincheck = { module = "org.jetbrains.kotlinx:lincheck", version.ref = "lincheck" } [plugins] -android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" } -android-kotlin-multiplatform = { id = "com.android.kotlin.multiplatform.library", version.ref = "androidGradlePlugin"} -ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlintGradle" } -binary-compatibility-validator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "binary-compatibility-validator" } -kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } -spotless = { id = "com.diffplug.spotless", version.ref = "spotlessPluginGradle" } -kmmbridge-github = { id = "co.touchlab.kmmbridge.github", version.ref = "kmmBridge" } -kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref="baseKotlin"} -vanniktech-maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublishPlugin"} -dokka = { id = "org.jetbrains.dokka" , version.ref = "dokkaGradlePlugin"} -atomicfu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "atomicFu"} -kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "baseKotlin"} -kotlin-cocoapods = { id = "org.jetbrains.kotlin.native.cocoapods", version.ref = "baseKotlin"} \ No newline at end of file +ktlint = {id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlintGradle"} +binary-compatibility-validator = {id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "binary-compatibility-validator"} +kover = {id = "org.jetbrains.kotlinx.kover", version.ref = "kover"} +skie = { id = "co.touchlab.skie", version.ref = "skie" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } +room3 = { id = "androidx.room3", version.ref = "room3" } +kotlin-compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "baseKotlin" } +jetbrains-compose = { id = "org.jetbrains.compose", version.ref = "jetbrainsCompose" } +kotlinx-benchmark = { id = "org.jetbrains.kotlinx.benchmark", version.ref = "kotlinxBenchmark" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index b1b8ef56b44f16b14dc800fa8103a6d89abb526f..e6441136f3d4ba8a0da8d277868979cfbc8ad796 100644 GIT binary patch delta 35218 zcmXVWRahJi(=5S)EN+Vjg1fr}7I(Mc?oMzv*y6gly9E!f!7aGEyGzgjC+~Ouo4J~c zd8WIox~pp98Mbp4w$=_^=~nUgn1Vbr%QTuiGsDF1$)9R$bDUfIH@}&OWyclA8D*J1 zpqxO=Q}&AV=vxS=OZJKwLI|kwaf|S;U9Vj~z^KT7L~^JT5&i-L10xCp4a6aVVn|{@ z(djUtQMI3-)))X>LwHs)H?`GswNsNSN||YOWo9pBXgJ1aFyR@e*RrG42#yg_t5oU@ zd#e1#A;-^Fo`R|LDEk!3YcjOR?^M+i;d?0i0!chq*J^3s{d0Z{M+(i%1N8N?N~5s6 z{cl#gnPMm?De`_#i?GUXdFith6AHf zbH+?Dv9#%pR=vgQ8ScB9Hq&^`aoD#f7x-L265;Te@EB)?oaE{${P$x;#Bs)RcbD<4 zE3ap(Y=U{+RN4mv>psVidQ&D-X;b8EA*4Z?Y;tPl-;hba#9-%nulNRcE|N=U+oGLp z{`YI3#y&dG1WYZknt?Ko7_Kl}Xz@7;E?rP=OF(1s*l3vEuSfB}pUM|>V0#`p)=8V- zR@W^(<+-HZ!rnd@&4gTs70^P56aTRA-^gbyduUrr~R$zcv^e_sVSU3wqK zXzd=vIx{=SayB;qFYdd+RiAdRB3;Yi6kaM8O91t@@=BqkB3Os;%N% zmgVJ&U7JdPf3fMseyCCr7&IfB`$0+dS>s~03gXigpRK$#x))nmy zlxoAAXY)Z4OKiGq%pm5dLg3;xq^dpNqUI7!l0q z&Dje~VMeH)ol{I|Ket`T%J)!#OdW2Q;qVR|PptlIP!0v_adq z8J}^j0dO1wp*fRmIb)vOZ9Z8>S}tgdvn`Rt4k+U%(o z9jx&QFC1Jv6PV@%Vr@HSKQ?~m#rus#+{^^Azi@Xbs{g*4Am(YUV2`D>LY^gj9Xmll zt7VM~W|t43<9b6jr!=w|qU_r#+W`zHm=h0>jRH*47N`X@r{fGG<4RC7Suxn2Ntegr zqA$&MI9V=sZ{BZ{L2Kr8%H-l!UST7!=az&4rf(wz3b@@n7lUEl-J&1rG?1f)UE^C+x!@ysUx( zK3hTB^}t$_r$K(Ffump&@QLCLQPesp(Rv5tN}5o)84ELoscA&G`A}$*UwKma;USm2 z8%9VXVS9FZdhF(7-_Ctkd~BWV61Q1TZC~E@lhD1m4PyT1;hGRuFDg2n!vvef&`xsh z;9;;%m5V4gs#G60+y+}GRamVS8UF%@iRM$1k=?The}MpRDt5TLFn!2Pk^BZ+K4pdE z+s)36>9Tu&;I#`uoPAscj(`7PG@29Pqa+MMSeM#~0%0<)s zCZdQd8IWk7xU$@;{g4no5h5YSJz+>snLb!%DoF5frji{Xa$iIsYLYho03rAWe7>DT z71TW%KAB?68o->f0T_pvaBYR9eBx(teE--d(N#Z$;WctN!|X65JGp?;4(QAO`>}Zy zVKLNOb!R`N0Vl@}@VYCfykOVL+UI$Lt=k75d{k0pRpTQs%v!%bLr)5Akvas9j)PTl z?hkC5$=-~7AjgJ5+-s)85f61cr=t#t;)7V(M(ysL_kU;F)c)4)NM7sVdph&RtfpB4 zFJY&Q*@{|r^?~1+?Ua|;(Z1kwrp>#MV_4_$9Hp>FQ&I9{22#&4IzGY5b7>H*dqC>U z2dx*CeMt%RZtu85ai51RBj@LkT@7wG*`D!6IHfL=(dndlKKv;b@ig6SS&0}|4a8ke z$W5!nk7E~jZ}JK{HPYioZ9q>PZ#dcfHI81sRGz`872y)?d%Ma0a1dRn?*9GWroJ+{ z3!}XIzdkcB===l~#diSPDbK0^1JFN(>Lo>abw@`6nv#*#Nq!S=F=I{fQ4`W+{Jf~u z^wC{G=aK?LA_DF|S{Im78Fr;V7DhWQ5{kv&3^3a*9tvIh<~+D=ygfc0qkdOiGxqxP z_bK~Ky>a*twMpt6vZj%Sdc}o~X5U`d_EN@v68v^G8^QxTvsMeZ^cXLn)th@vofg0K zI3h`p*$%(PR%#1`J2aUTNx0Ltym=qRJxlfLhqD7!-jYQhelPbKNB&svA8Xd$pMB}u zm0H+iCqH@E5cx0X^bPbm6?)hXTK3(S_o>y3JZ5E-1ycsjX8_)9;|9ttuj;1LE)nNG z0bN@5mGagmd;~UN-ycN%f~~#lh=~uatW*sg4bOOer=sI|6B4Z>ST7wW#BY+2)8Og5 zZbFZihS*f%4DBh%{kOBAf-i%PgxL3N?tDD-?yoAmeZ5=1KX?6}rD+~J?jSGXX(l9$ zOq!GFZzEArSV6DqHK20m?Ds6O5(&4-jV8O}=r3qWaQ7~EJ6_csGXWGmn^5q|`C!J>jI zCTcq+zUcly*eY97&!eZ2<+0aHcrD+&zhBhVrPC$~T1pV|o_X}H5a<%$us4r&ba-n< zNosN&jO&%S?QsWKcD`$3R33g&rp`%W4`l=}&vJpUj-wIxS~E0pFANO15TvXFWbal? zvJHCp3c-rT|DLDZJ_6yE&#-a@@qTf!=C*jgo0O04`OpB0Qv|(f@rREQ+;_MKunTXV zM_yrti;Is?O*+~z3Exh_r4KXu1EbLIW|LpAl^ z+C(7b0(MgJ)DfP46!UNvj|INS9ZO0uWY&h=dCXq;{$HMD&p(OTRHIWYQf zc%pXM37FtuU<^L~Z+oI4QUKd{;#%Mb7|!G;Dn(FLnj{op$q205=+4D|%8-yK5)>1F zE`66TK2epG?l(r7o6wN*j{lD~ zD8vzPEwBB@|FZMaEw_E1xBKrQDoo}zE5tLFbanCG*;+-B{GbHi76V)yCZ3?OGJ4<+ z^6(gowq%Zht{ot1lPZPvRp2OM+}@ec=6Z;qgDSSKWav^OEa&w#2t*#YnyOZ|@MjuV zBkw`peV+n3E2Vgm@i+@JvsT;EDUOkvN!z0K9IC$RtrifGBa>ErFhioL>c~FuJVLZ; zS+3`-i>|Uoj4MVHUI05-{H!P+7@0Mk|JLIFN;XTx?dt~H!YbQVF-8p!yA(7=dWDobfmS?@2~7}iE>Wub;}t4MKcwV)e6yzRK{o$f zk#LCKm-tBGArZ50upaKQ(jp|k&t(%tQVz$S*iWkdjG(b(<|z6l zZ>n)r5)VV{1Pn!S-oAS!_3h;>!4h104Jl3Ok>!Q(YOsqUVZD9vl)q}QA7j8S1M#9D z@mITAC?7>~rQFko7RBaA228bTYBPchT!o9S=sweVFN;>%aF%|;CU>I`aRs_-stRM8 z`hVE8HQWSf#8*HO1leUPju@gl*wTQ+i!^%#FjWm!n3<+!}KCrR%C0fjds zU`TXI0^D`d>TL5P68JIbXh;~IyKh}k)2bwWM|k0KIG&@Ty)SYwyP?W==9{v9FdSfv zrL>i?hM1_CdX2c>Uq{TWxj#NXUu}czJ42go{OFhQee16r5{yl+cr2d@h+0~l!t)0k zBZ)0s)CV{SAuR3~)68x}o!9{OHY7WI%O@3jV(?FFSHR-4gn)sWq(+pS7OaI8U;Y77 z5?U!fMo^-L(KjZOkjSXSk=pzjXF7rrzM#5KQrDR2zac;QV9E0%N0!&}cdjIr_?M^% zCgQeLKiso3yoq~p`8M5 z-N0Njq^}e#yUL~z*6|vj`wnru!uB;S zgE31Ty-MI^$~pqS_N6%ra`mYTj}+|Dpj z)Xr|UIZJ!ST;e<)=rwQd(8Cx?&+d&mCb$$nlBfdFiC#65<=Svf!^)W_eD#{b#p$+^ zTipE(#1yaoNXvd4)v#fDBXW=P9KlzLfOAx$EMH-uN-F$x_Nn=0PAu3A#{n%u#QBFR zYZHIxe3Ze3unp8#d1ZG4df!zl8*PI18gSqbl&AW@6QceRLrc-*7=cOoJiT}H3JkB+ ztYEBY4r^D4qq7K1`{iRoA>{CDFzA)NK1X`;b=#$qRQgVcCwFimMYbXPi$5Q?k`hgw zzcF@mcKi2gT$`$8oJUqi<$)i)BM?Eox0v>l_{aKQU8n2l4$iF7O+@&kn3gc=kZ=50;{47xQ+|I^qq+#RuFR3J%%jGODAD3Vq;cWX-2&5RQMN4( zjQEjg1y%a$4JO1X>}=OKb0s6qb`KTqdfmAFbsl3-S%j}r3Q9iB>S{PIT5F%|2J?8%&;+;aC>#83;~D;JJc4;E6)m{bq$kUGDnDL$u4UM94SQ?3QR_hJ9dJ=vvY z>YLP=(8^1wDnVO0UDuRso&e3){6~PtD;PgF5n*6V(P93{32^YZ&~O|)=p%(DIEKg$PqlYgz!PE2HpuC}3el-F3nY@2;3supbrt1psF+aMrnA2JY zbEW9;{tg^)c(>*TetUj{`$Y+WV7$Dr5?wa%@Op+eG~kV&S#yuybyn}*3||*-Kc##E zf70&(iIIDHS*Hp1uA%uFmQ}ffTZkhw78=pwJke+^!|yvSesoH#uDp-{LHYX+u zU573<+!1$KAM=qcbNNkVhQ}TCp9_CEN(y14KnQJz2FcR6D97Ue;Gpz@U2m6`f+A?BN4V?>emp6^S*ZFYuT1Ze9r+ zdbT#4R?%IY+j?h;O_M_`%(^oAbR%E13yc$~$l|P(L3rwPc+O-5z(Kz!f`AvKzgWo1 zi7QvR*mV?9nar1SI8 zV%gCmn=@~5DF#PVmI?1Dp3mh0m`^%-07BvAM-KsRz;2yy;bEiXRbsP2Ai|1Y=mblj zgb->2QNG8A!n?|uRa_1rrJ$a7_7jA2zJ@M9%1?31Ku(gADne3KuF<_Z?l=y)Mrx8t zH>I|mu-q#noUq<3nU(I|rBm;8cd#^xKdM!OC!OQAu5u+zBtA*?>2t(#d5BC6I)fQ< z08;w@R2UGgUxC8EJ2(|;l0yn@qLK$|{NJTQoD@S5o%y{?MU1zJ!*R3)DIFdS868$4 z$Y@yCmoBvFb1A8K^j}PHF!`LbEZ6nN{|*&Gx>RaAXwK}R|HffwR`cuY;SeiKVVc>a zpI=ADYZ-n^7p*=hTkVench!lc)nY*cXw_JU7 z?OsA}otHc8(oQRn0tLD&WfI-O;k(~$Q11ZSL0TOHkGt5QPf@g=5kiJ^tjg5;6J!R` z9mo85QtHD7Int}6^0;29N9dDK%^*~F?ZYQrZ{oO>9NMm~laOQ7_YuEmz5K|3DB|oL zQTen+fYJ*%u9)BzIspTT?%r_`J&sd;A*{F1aE_}SUg{XWJ0sj@JAd#UbY%voY%3NB zboxB*wF3*R!}IVTfV~j|FyZ<9Crsbb z@!SkBBm1h?Jc|4m~_px3Wf2cuG{qbj>EP$GRviS3e3<5B6dU`Qj#& zrYSn%tm2Owzj5_T|n<1n{r3L(nZm-V{R=yM19L{Ge1>Pq$e{;==8H5Y^i~l;Iw|(|YIJOEu*{+?A1Izf?E6-!5qwEs<}3Fs=k> zkWR_H0p1Rg4;D1cd8D88-xgx?duKuSP5SYI@dqj{NBKMH<%LC(TAac*AKUb>4DD6Y z58T|m`e2sj+l*!m$SACw!pcF80HAYZ!$%y{^?ypF-7WUX7HyaGnKuWel676sTHMJ5)w2=uwhCm=1qF%q}ejZ*rZEn`5^XgM< zOplhjoTA6ryHJ%GPo#5Xyy>qYyo)>w84zZl=l^&wK=g<~z9XsHm<@KHi)6q-n~O=C zrwMLyx2qE*=lf5lW4?sQj=#7RWJ+e39Hc#X_3d}fE^)_&;#nn_fMs0v*^<5m7@^aD z1*98)66@hS9(&~Pld}+VA}J?}w=ed|+h(ZVPZ;n(<5y7LMX}`C3sa4tAovJE?9(q6 zC=a6V8{Qr?DmLPAhXNu@t9Oo621y}L$iX`RCL!~G5n`` zI)zf4a}~BsWZc9Sz{+x@!BRV4iO7uk6THey&~IfMdViJGFo1kAX@c_O|2AnGOyZuP z@frkraL~!S@rRo3kVp>nH-G3YqlvlK6kw*5M5UY4t3t)qnpCaKHM>V;%bW5o>=!Pg zhs|i@#FgI{x$yhd%gfYg>mfMZCi?XEQnF*`gNgs@DBK zcLjg(m;~(~d4ZtcaY&&VOkcpNV{&lpxSe&4^R|s$+AJjc4KprRb!8a(bSUDgbvwNV zmIvR6#;K?|nZu959to=92?D;8Q*X>Y2L-`F-P|TeT1PsaE?H|}YSB>vWaMNMcAl}5 z!NeN*KhNP6OHbeh98pKq^OK2BG3lLDD3#-f@s-!|LVFZvB5`v{y zK6%}ZgwMo9+>JH25pBp^ z0j}-h5$hyisKp0}gMQ>wRh+rS%xu#+d~mDl>rhR?%Gjkw zH4wM)xv-rmkJ!2X?b1yI4J>RM){~}1tLLA*LhfGt9`ml3((p{puR-Cl^L=p8c!owv zpf2tp=mq|HXP%*^PRiiP>|Q_>DE{ex<%=H~i717}uy}&;M^RFo@Nl)~n%vv} ziAcZFK1EC~ea9O3qVW6t!a{c`?%)ESTZ*jT>MD!|0SEjDBa-CeNI&yQwl6o^*XkgP zpY!KG@jbU2=C=vt-}==vc0=1aS|yCN^jzTRa4NZu@|tmay@UqWhv6U=KrhtuS*(YY zjBp{g_P(Hv{5T4{x)vp&TEh>Sw1fQMlknK?GRQ8~*A?k;S`>~WnMUWe)Rfz53B;U1 zBe}UiB~)d;NtM4Xt~ZJDe|6&pf-HqA<>iH3gnMc+<%Zm;nuSgDa_p)@x+dY;#T8gv zqC>KLNY4?c+lN~$>_wCw=5%090qh2`R3Ns#001u8plebw z?qh5#*^gNIIlF9A!n>cp70KFTazCY#wFMH3nqs!T6AI$!EBFNjQ>6Q*Y)(yuF%lLf z%*Ts4VWVqDi}3@NaH zr^PILzpH_5bHYO*$kA41J253M4J*5DC9d1z(Coo9h!V3ZV`6SnED#bWzi76|nOeL@ z&gh8CYLnKRO|~{@0O@hVo*6C&_cul48T{4r(AK$D3y=qWm?iw@2w7B437Q^hQ)-6ChzRm+u&j=fdN>miwCXbPeeG>BFoW<$^1q5k5%L+Q}W#YA;%E>e}@&+ zGKcn`FN+6lW5)u2^~BM{eM3i9AS#%0=F~X?_LSJnB1s;HMp59FJ3wb}ZLRBaerM5u zXgN(RXQGfWm~suGq@RyaYZ#Uan5M0a{H|ypMf?3}I+sz$gv=+-ZnORG-|jay|6Wi2 z1C}hwY?I^dzPg;3YwShK(Bh$&ofU-+&g$j)3=`MM+A6Td`~t!8q^msjVzW;CB6yf(_!_Q!SX$Vwn~s)|l}Iy==8vrP)X) z4T$?B`Q|uR1_-!Bw(qGYZW(gx8*+qB{BqFlzkf8& z0|pyT=08GDf6U!woBJYNvXiWa(jZ)N(#3qshb_kLBc|YPIdY0wH{lLALn=Ou0T$1UTgr)j5C}YGrQz6Q+L5u*X zuA^>KsjGoDvH1^&(siEXc82B!rVM?(gMzlv;?Z%vP<8F8-?kM}$IdYoM>7f)4&^-W z?CT47y_yW$(dR0DfKI_F?p6?YQz2ywM47sH(UW35H*2zY*fc!<5^PDu9>72E4Q42f z&V(g1u^x94FrT8B#nLy@@L&h|7vs>qOZ^$CN%S?ViuhY$K@!F~qki7utm9CmUp{ zLfUGjK1QZ!k8a^SucRb5)w}iS6&&Tbsm%s5nX;mw#qU63uwIVi06_925g~mM_vif5 zY^drx6-5e-0zE$8(q?%dM%x&Px%o#Qjz*dM9c2hDG+qDL%JS{1wYWg~CG+D9G1<$x zfAyr{eyQh@cPOUqTYdBWu2nR0cR8~v`%W586U7+b)xW(ng%V2z?yV#L1O`)d{ET%N zAS3ssYx5+e4UWqZiy1WfbPX6md}J4xYDIaAf?biHOpetifJ;?s@C&O+B&D6WN66jP zVQPuSNOk^kdojXRN@4Cc6=&oMEMo)hEmLj8;*cF9k_=Is5X9V zX1-rzxt~UR(W&E`xrWWHkubjDFG;0<2nPHES6aTDB0QVxOTN^?UuxN7&NMk)Z|%3f zx$JRoeMx_s8v@R($Ulvd!N2_v)JUV#k8Az`ni>S4CpBj4S4k%jwbN!8}!@E zv5s|~$`>jLxm6M(Uzk~(r3K2VBFTceFTx6z-)3xRIbAGg5XPv-K3*a0A+!uM+QM0u z>w9nCAavYGR=!Vsz=jszQ@(GCZh}`fn>_-_%Re(cF$BWd+{>wW=o&g74hF|%zo3PTNf7tq{R zYpbp>FGz=W`S)b1A{6!_mM9k+^M|ocmLNnk??}W`|3vb7Z|}D`NA>ON)#8VLKBQSH zY0M^@!USf0ws^r-S=$Ish7Hw4S?t&!&2}9PW?{)cRF8zQm*9#Q+g^<-aZ}FbF>YmX z@;>%^K=&UO*`d|d& zJq%%KgKsGNlosm7E!0lSE_2fKeR%2qQ4kBSMW9lQXSt9|To12^i*gS;U?d*X9C(tZ z-YZMkhs1!C2ks{CJGgZK*qKNvb?8CWGpdqR7dtDqyFVy1ot_h^6;z1_h&`x{%(%=QT-V? zszJS6fstxRkoPFs-Qbp}8dZGLOr?uymCUH=ZnepTU7b+O%r;T)SUHby1S>J%;JRUzp^VXEVz! z(;-c=6kz6Od$!s-n2UIbP`mf(ink-Kx-$V`!c9UDUgc#z|FmIpJw*rdcg;x}cnfn?#2$ou}i?{Hl$Ud}z@#py7f$5ogY=`Q0xHoF5t0iva~6WA2t8p(=3rg;Bef5E_~`MgCZJN4O+2 z1qZ?qUkhv<#mRI8>SR2S8}CJnehc5PDwPHYU|cVS}ZcQY#aFn zegz!s6t2&;>ipC@LEe*be#OlE+8})e+LjoQUjM@^BK$1u8XlKBzXCws=UHKzdJd>Z z9u@rcbhxA^`iq*Q98Uie^x_3s?VE2OfzrrhVfuy+Oi7=eG#vi<*^ z*mgkyaQwJj-xu7_Nua|D(80muloC~y9a0hX82kx=7atd)XP>K8aY!kV1BxqgC+3Gr zKuP!C+RjJc;^x-+kmWRyoqm}c#|q=CW8vi;(1lLk`5|sFX<#FP0Iidhb#yi&z5~S`-z96l}W-NeuAik+nbz+g9MDno%AMTE&`ZP|D_GIieS#-N05=> z(asVKEhWwXeQ;x|$7yd*0UR+4=#YG4us)bZpTL8QNaq(ypWkE$fdS{zK^&nLgVRDC z!}wfm`hrP1y0z10r`a*iczcv*L(}F|0D@(M#R*B}|6Fgq^=}RuP!(ZQu)3YwtQ4*= zai$ErDbVg*?PEOE(cZqFvn+pl^_ZsGU6|ym8Ho4GvRU+&*hN1$)ki|~#8pG3Y%|lO|==_;cL=H@XLLIg_4#MaB zy=iQBL= z{=4nS`ZlUERFgy3z30g)eC$mVO@7}h`1Khh>Q;CsF+g6)7kPa2oBDqS%D}0z`Gvjm z_dOKtx){E|&Cis8@%?|)Zr&=Z&fS*vQ9NJWHhe}9mn$v*QxKt>L8opgCvUd1R0^tL zWX6W$hMeb5@meepsMTn1n-#!f42ha!xZ}QzGi}weIVW1SuA0_n&W4!$`8GRN`=s!%c$_uH~%+m*HIZV~eLpF>SLGDkw}Afe3n zH>$U8V-8g>LPV_>s1nU2#R(#}2zF~f{`nP}&iJW>qi6z(0Kp7*uWF3Mrg%>;LHvDs z`{2O8rfJ{7vb=qo&o9s`b|0sf&u2hN=PHBGXQVs%cIS*Dj;#yLpGknGe_0jU7kaR= zC{hegoj**pFHFO_7}%A3DbbHAL;o8nYj?q~^fx$HN~b=6#dLM8+i2@7_(mRxkwM*T zku7*n!uY;3dMdui?W2@P=1QpR5q+2JOl%Q?pjB{$1(dt|pHT2brHuRj?^?_HpGol| zt_)UDR9^jp>Hig5L0nD7=IO%)0%i2T5Cwupx%j?>W~0nqhoN#7^{GdLopfHs-}tw{ z9R7UQt>Hj&O%MA(pCd9$uFFnuPQf_v#l?p+Q-iI;KYtddAJ`IX<#^CA#JSO%#WnXh z9n8y7iT5Stq6ImqgxUEMMAUH9)Q$Eho2ys_pq8%Is|Z}h zvWd!MZ=dr}8=^$;p$#@z-;mnf9!1%RL9w_MTW6EcLBSo1H$(wI>X`HMQIzkOd|*?> z7@5|z?jQ;xtlfLcmWPd&5&y4@FQ;kBp`;DoKCgyf=Ej`;`sTm?+*%{sFnub)a-Rad zM+Ce`)Bwn*$lnGT1C6Zx+f8Z*3PkV?IY*4_{RrM*|04(QM$gf(4=^xr&;%AzsIr^_ zSk=I3mJL_fa-E|MCb(+%#caDcxa7kiNC%4*BqIaL(}epoE$gW66pO>JWQ6%b@i$V~ z0~_ikxg$-HL56w*LV(d3OK_-5 zLB&g{9gsq6?xL9JC42AMFE2`_+M%rl);)Frv@(_N;+5xK_Y!zuMCJ%97I=)!Zc~BJgbaFtU=jfTZKP3x(u{zHiVSCW0GcjaqSb3l7_T1hP z$3blwb{3(YZNAJ{&Ko;#>^^3}cWI?D;`b0B9Pg>;ex$x6z>Ufe-*j=%uTe zF=6w%y~0eT(Rqdxe{Mj;5WvwF>>o$IAah4on&W;Uu?+ZMVNFXBBYXZ8wh$VqU3z7I%M7R9g-nd>;g-XDmuRfyS%juhXb zU{mpfb(Zg;fgPjB2(I|~Y^3SVSG^iE* z#4zo0^iR}oI)6VQ>fKiIMM*Wa>?1HuFl`+9KJ@c++qBcWyGb-=fYeBB!jkw`%Tfo5 zv&8IF!`?VV>0s5wTaJLuAT{f_AE9H9={%KFKlBsuV_o}>ze;|G%lQTp zzK{u75k$|JANYY0B!_%DmscPIU5fGFZSgoeTTx2D>^O@F6TXJUxEki1(PNFhi zQJ$UPW)tY2vL88IbL;l)a+^4O88TuugyoB=0g|n^H*2+;HMKO-`>pD8R5kJ2>4`NT z`Uttgb%W>7?8RLEsi`2bOj5Fx%Gg?-z82yPd54Qo(}gqsVwTHf{bW$XM0VX*)R5%l z=EK#xL)A}bS2G3uAw!5lB?bK{m>j|gylfyJH7g+nFH)xgw)qYEY(Y()&~Q!41Vxl-tAI1 zbZ|gooM9VgJ3@&4Ox36H3J3qk{O2f}`79C%nJNW=2>jpfWR(h#F#WS5X4tziJom9Q zpi=5jmZv}ObKe+W3>jqF)2wZxFcwlMF~8r8*Pz8X2iUtRk2Oova#`fP zT0Z@A4UTESqW9*`GM;)~iyE-aj2vD>`SZZ83FnRL?iWkhxBY&m+sC9RE$PSbq7k4> zW-G3b6} zh;iC+lpiK$AU`v{nX~(A!~v{O z>p-I5V0NQRd7V~qfcB^im_hxUMjUVj+#~=G_H0i7J|gse4!8(l3oIYcRcroca$!nS z=-VtXK5z%pK;lhT6cuYtQ-q2pC|>UDp&xK^Mjacej+S^jAJAZl3=B6K)h;oK47F;v z9)HAOFWCB65z<*coUs_t=giLA87zQ0SvEPLh@kDe*(;P_#o6Ol7EP%Gm*R;m3P-G8IQiC30X?a77rz-o(NUMMs97ybDh%3@ z4ZF+62@%GpKQE`eFuOX&UhQ2u^4$w4YhDy2>SNYYs!?{3ujP6?56p|Q8v5!g`Y2zW zl~wA0cZPsf@dr_Bw5}Tz5>|7FyVHnoqWb z<92|W=)|$S%{TIfr+uEIpfy|wkq#oS(zJo~F*BKkNM*#&GN7BXO}U4gn2X|3kXZ87 zCV_pgs3x5Qx~ZgHDp94KJ7k%%k9XH7Lx~2|Lau0C7%)9D#2kKyIH%+b0@K|67o8m- zJ?o~Kg2i(nQD+ox;9xi`+943V+0x1`#Q{ZN85PC-ZfXPp-9-Ts^5*md+tWCp19 zF9xZqr+({chO!MzwKJP7(woE!4%_~dd)|knDAw{uo*kQh??)&3#pHwHM8+%9dMbke z^;{#ugSTAm=civ$98C=+MG=F7+d=!f&w1=pG#Jy$A*v7|Wo3uE%Dl`4-#Z=&@TG&h zw&@evU2K5V9$e55(W8c7UFnZ2$1G+Z0RR37PrAVykF0X&y9h-+vasm0J>0YaHXOa) z3GhA0ow(~WwEG=Sy-sUt9)GG=*&SWqXsLf(NcHysvgeDxJ)GyJla0yOGY9MU|8>7- zVH3^%zbzUI;s3&9Fi6ly931F?CM#GQ*^fk%@V(od_vgB!e7yslybUQx-0i%CfLk$d zsz`fSY?!88KFXv1r9R!#(=t7MT&$QZoFH#URbCUfCbED>oEHtNOiynBPE|-%^|}i~ zqoZsyb6Jshgy?1V@6D=H*S~1B*XEzDhoxH#5rDDq1P2MfS8eI_mx1rtqz7QV%7SuA z0UcE%ta&IscfL+VH3HNp8fSeAxjHQiKMa=>NV+p^!kNv*n1PraGIGkOc5SRCi=asn zjA6e4BwqUB8#74qZY+@SDTu!aeldbsPk)||SA$t;4$CpEDC#ICA1!~-I66*a?d*fu z735C?*1q5zyp>Kl@in9+zaNaWacMd32FnSU>|b5*pNSXo zD{ac^QAo+})AS*bHpSS;D{{Fr(62OT>@Ji!0v;-4t4o;?7Y3b8G{B3)j9Okf-`h@v zkPq=Q>~4nJaovWo40+<{dX!mO#pgBbP(dypirpAvb-hs*bXGcsGEE^-Kq38~(bsIW zaAOs>$ynvfVNrE6Q7|kfIvjSgy|z1=%#L&^N-WH|1FI>;P#8j1{b+)`pdL}MfjD)r zMEc^u5Yy_(secQ=4vb%jG7n%Gr(vBOnHYmf)Q=LVtI080X4=$tB%n>iqy+c3&QA>a z>oDH)7?UpBADhVU9qPBDC+|7_l47OOSMrVMG?Fc9#b9-X=SobV$ND{Z@;9QQG}<9+W}40v0Sx%^KptkaXN+trKKEjBLZsV zUMVe7vpG7o8GB+3<9NJrK^6u?(b1TksLP_r)r4yR{izbm+TCYwOqxNyb)eG)G}@Zs znbSJy%CqrmFx^5t2CK`rOE*)0gAC#%rbrNpv5rDNDFj=2M$9jq-~B|V{NyRZO(d!5 zJ!bscU!RWLwB>QlM>&HeYj1U^K#017$wXAM4?T3nR-wtAzZgi9I^sb_1+*n-YV#bP zl1>hUpFElE078_`xkVATUB=Z0Z*VtcvK@!()$)_TNItnEXc)hB*Lxk6Y$C<=xzfi_ zaFu#E2nUu-qm8qCe#AQ+F!KGv)T3KHq{%zt;#L1xkuzJI9o)4R73_uZr#3x!!o(lX zK!>Keo?C)!M1EEGt3zPTV3pxo&|>@-YJ59Y1#W?Me4y>Yp1;V#bcG#J>vx_+K8_WB zb2?uc@X!aRG!J@#x+G`8@~qDGuk4C#A*N&<2`ss-B+NG4SU3akmaG@JixLsWGp2wZ z*m90;UCSqW9jAfSknm=8f-*+~uU*0Z!M>eC*V1485i1R1>tUV(2=^?A&7`gv7c`lV zF*X^JKrP%+k~Y?jDBq42xJ$DZkB5bv>Za;!uxqZY+`d6)=X(7)*!vrddG>{|?zFik zr*{%j3Q6{LD_y1zPn@}FqUQ>Fve_Ixn#U*R`;a|HUt4VZ61k7iSx%Hb3Jj57n2&MU z8ogm<_}1Uj-3mJN-q|kx?9hpbTTlTO(f-h^I$~UW2J_M0Z0%BdUgE_HNpZU>sn`Vt zfmybp8fkk5#HV?E;XSl$r-Nf(3xx>Otmc`cxS5v78pw zYFPqO@t(+k&!hH0FSIwnMOHFH$=GbCnn6onrz}9)1)Z&<$EacXWp;O5Y3CzZz%OS|H7LrKUPY%ws6r2fid$_vn&CKNDUe){na#pq z5HqVT-c`~KMY)u2)t%y@xJ-FwC#9^QDzNc+b)I%^8kA-DLoP@yRd#s1mz`_zF;?Y% zd#8uBJ{gZlHs|wOBn{F!z(y|77kou5XDn`s4?bjKka8am77f?!6FDgR2ri)grHApR z@F1PMtYR@buW9-7)d>1<++##vi0JXTT*r;0;>#Psa9KM4z0+g6Of-0kWL++Td4ukb z=TwgNW6Lz-g?DA4w?YAJA*zLGK~W6{`|@)9fgJISpLA`AmOQfABZ0|_3RpqtOUpN+ zG0Jmj3tOzgC4pUUy*R6JHWO?+u`x?Vqn3-&V&ho<5FUee5O93MCKYHfG2rwVZH{X z1@_|02-Td~`7AK@5!wiq5jI8DRd9j}7E-eWiE1s?W^G8rR`=TykG9M~H1BW;RJ;-( zo0VdMBc9R52Ip|rf4-`>l!yO&+l=*R-7H<^U{83S!cH>n)e7<|`19m!4S{4?(Iq$h z*p)!X2G%HgVw@ZjOXI+rWfXseAf*A%TM5a=osO$OlayhlWL$f( zenPH~pL1Fwb$;q*_R1YSw9rjq<{7FW>6`u;x45`~K)TEMxE+GiV3n1}-235Xn*J|* z^L9KM#;zmrM(~S0pI89am8xyCBz$g>A>Ed`*9v5EOP5#x{Ki@ul+RG|m!aSn{w{xu z4*T@zmAd_J#7DM{Z}n>|^@=U;hx`xCsAsACl_2Bp36pHt7YFoH?4`@eP{llOme_hR z*)}S6cXRo_&$nONf0th(1*F33o6v8e``Y#uQ+sTym5ZE#SyWtM-+yarvcKZzKmIR( z4TP@+SRf$7z%XD#uPuy78P@~ybW~L|%r)tdS7UZSlBPjwTlt8ydN{cm$501LM)2BD ztK7uwOUlssg*Jzvh#-<|zC>0LDN$zuYh|{Lm)G6?r6M;>)3o4Qoi& zC{F!Y-yBFUrgc4X3|~fuo8_SX!v}VMgSo2lOFu|GkAZk7je8j-aNK}Mxq-vzm{iQQ z7;n@!>J7uDvHn(r5GZxRR9Up^dL*zKD#vt`f3!U^Un?0!q|CiZ*o{f;hd#B!aFj*` z@>Ie@IqRz_h@^PIGs=+j`O4Yj5wgn!RqP+3xKhJZlP6TfY^ZBy;vi zK-;oBH*k~30=e4SOM&^nNl#MOvq=?D5t!bZa4R^EatSlIAW%%%T7AggG+g|{Rp%&9 zf5|;4UUrVlqxV+`)Bbb+{%>86=*0HaSpcy@=y7(^UE9*_=5?~&UcfOh7!uW-{oi( zQ+xszzhLIoN6c2MpRjO(>(!gQoIwA87(4HQi7dzK6mmZRP)i30L30-g*#Q6mvXhZ6 z9h0bOH-DvB349ynm49zE(u~H+VJl7|LW1HXM2;<+gpfnQ5MN20$WE{mf^rbX(%2S} zG)6}f(-yY0w1qAUU3!JGJ?LS&rG?T&Hn z+x(J@=KJ1z-@A`*zVzC|j}p-uDaLfy4L5Xecz;7puO2;Yq+&IpnrL55eNEI%4#pFD zHg2YZNi$|(J#8cmJ!4>VK+goD14i_4CYQ|Ag!=SE#;C6u><=dO!C+j_FnTwxjrCp( z*z|@XeqSz;z(Qs~53UvIf}Ug@D`YSE~PT5YxN($hvN>wlqkL7<7Y*hIVZq~W1$_8RQvI?!;fo?fof4md!= zVA@1*WaCCgrB3jbHU<;A2&!}jTG>$M+%lY$GvZF#B{Y8p)5>!rVu{lunA~kUye*DU zkcY0MjbdjnlYdg6M-y?lbTX-@Vqv5VAY_cRLJ=4>VAI>L(LOo=>+6Yxd4F_I>Tt?D zn%X%i93Ec7@Q|zNkQ*@9;H_V!Uf9hh9?XGOaKKE$|Bwt_=Aa!V>&s?{@XTD88U3w)i@g1`(9k<>E$Gr8HLB zRDuaBGDDfHkqin$t8|2Ewtqz?myIVH!|@EV-!TZ)V`Tj{pugY|Lw>W*pB*s#+pp~O z2aJaP27jTh>eqsU|C^qp%M3e1z_) zpbyhWi&RSX75X@`8QP03<5<=TZ-nkJ-CIGQpiiPsq^#xAdQ$Kn4}FU66L6nqs()R5 z*^i#Ao(1Rk!(Ws7P_KdabarRK!^&+D0yE@QOHd}vz z9v9GGMCLV9CDQIY#}e?mqS^!}=@-X7NvDO%o?=?_&*Llf46<2)UEvqaX-Ifc;2d}< z<>JfqtVUm=uYZ7A;POmuL7z6QE>ZR6bOr3!K}sW_mRDpqb`VY4tHvcXGHwPJoH1m z#Qg}Vx_}WpC=h;9><=w|nv0n2>9h#ApKA0oQ93K4W`8P+Ml`d-81m3BL_>8u{gP?H zR8d-T@kC72-(REnAOL=&(MzH=af$vrg&GKhZR;e zcClM16pFs|T)bmiHkfEH*n_ITvzRVCHynlMAR&aO6y!pIy@M;=JeRAAjdLl0!t*q) z7P*?6R9-McP86}i7$E8x0|cpzp}{ejo7z?u(tq@fWxphs^Ag5raXVwQm{(4#3S-1@ z;gIG;0)=F91r?PXnUk0E3XPXBri;`1#!Mk+;@2A1? zW|zvs`IAN(-pRBqP&$UqFnaJ67%;e-X;m4;*zrB#X4?=*38XsSZC?UR-JgDHZMnXZ zujIWN@8PS^5|%kNZ1(qyL-vW%v(M)8fX4gzpqPA*Ar9e)heXnAV$3f!Wk?h*KY!Yf z8PS|*qqrBoFOtp5vRav%qwF&cjiZeE=Ysq9#ZhBRveJ~JS}mAdTDQ{@4{+QKbj&l0 z${@9297~vbOyNX%mn=tBVe}=lF{vLl(}#D*lZGjh3U_#Tkgs#I$!IedfY&3CV!CDO z0yD^3)q_%CesecS^hpoJ00v(=zUK!+$R3?kTRN*(2&N0P(aUUSc zav>$8LzJq-0W zw$F$T-9E-&aKqsqXF7l8U|POf;U}2VXAP86FDmEm1!`sY%4Ry-3a2%GlAp4TZ(Y)$ zK9mflg_|3GhQFkd!e54SG$f*jRmSw;N*<13Q1|fH?415}IAk1sd>XxinMQ;0oV||8 z=@PBPgL9J#k5wnVaG;P4u{DanQnm<;g=I=ApexY{1Fo0j>7ntUN z>*n5!naE`g+p;fO*M|IEjh`2tg__b+rpUNf3%%XLKj0sV>iZ)ni9d5%Ham#F^;mJP zfV#U;Db1v9N$akYe}?%ueN^RNX#8_gZ#AK+0X>y6Py&_)CVySLM(x#%WwQbZRQGFs zNud6QY4KS>75*KZG@Bj@LrRfKW+E;2LH7^*M~#2aFEefWfB31w7)>gzTZ7hwnK7;q z7jlWAiR&DkZf%I+%xXyg%74@NFZ}nCiDLHv%!aavsl}N|7{;K;BHIP{KNwBrtBk2d zj>1Dde1;JFPk%~O^op|bDUt+b6u#7WBpFkTvY%XoT=X=afW^g;3U z#G11)j;>ZDwMis-H^~Isn$PSp0baNTX`=?Q!d{)}%6Stm?HU%8IM!Hy|3TbW0 zokS^=B8l%S@zfFXFcQ$lGDGCdm&YYzm$X6Js7dRk5WFiYV$maADgvTX!eG=skvbFN z+n8b9g4=5K7uD?F-rVhvr;5vyOQ(P3ip}W*H4q~Po%Br0wl~(jehtZ_>TFH>hUaqtn5EttNpr_!Ted% zDh44?7bBJ^_~LgMNBiR$f6o4VqDoyjdO}MWMhBn^4BdYFni0b9!W?z>i$jEid9;`V z5r0J8C^1qGQ+=Vj`X|OXyFv-IPK>nH_=AJ?FkRM)T!*C?Cc<`Hv)y3-+3uY2B#y44foPR|P78f*Kxn!@A-Ys~brQ{^VDzAt+Qt^+a zULyCO@vDMJ^;S9aG$&8hd0LdGrIB!+R#w&Ksqwh-V%;cR@(^ue3J+7&-Z5&5bk^l* z^9Z%ohga1V-?!r5_Ns7ISDv~@XiuK5vj2w1XkX;OfvyocD4!(V`3TiTB95wPkAGZc z^yDdCRdBlN+C=wnPFm(TSOp~*eYr+rTG`!h_Bfpkd8(~0B!ai3C^7h+Q7 z7>xo~ppFfnc^2!BEm1?NMOG~# zYcum|2p~Q+1OmfnPQgxkDYLJNQ=&8SRRCxa7fKZ+=y`c@o|o<&KJ%Q<62+%Ds#PJ4B41N5wYB{@ zpBxE_BUe^Mg`Rl!-VKLx$KW0bpQ z*qvjoI6*~(W-uaN>%6>cgnLFg0vW`D&snm<`y*YDvFbWRQ&BqnXc*zE^Q<3tJTb=6 zNThy@mqkW6Hp2ZU_;BYKCjlO;KfxJTVw8^_8sY25_>GZHMBW(R0FutS5q@)Ugx?DI zNY@E|hoDcJ!LXNa&42T4k#)-5!m(%jT-CPDh3&k2dvQ;LOv*6L^0^$a#j^qINK^ZbD2TMAx33(pcq)60i! z9}6kMcd|l>{`OGvpqIk6^`03(|Y)LhhhX^|=M5pno8Af*%^ECBi}wgF*#0 z_X+-dB%~FoxqZ&C5K$`w1#&)DD&)>LzDQ5;8%J6t>s03 zigZ0iOAzLu@e2M{($hDO@@vDS?eHmG!#p4PsSCzE^T3W_cKejm^qsoA#PA(xL2?1Z zI{uZ%Fzef*)_EmW2qc84t+#}zsUMTHNau)DQJU&0|MR{rQh~yuYmgD4rS6Y>q8n9at2|K*fMLk|= zHP$b|ih`9@J&dv@H1zx6!iGJmY+4X&G%%kjL)b(;l$hwD!9R4!Mx zThC5qm-XycV%D=?xlugP#6n_Glmiry z>#0^=ORMDdR3|r6y?hk~<%859N2yW1nJ$t)MyusvS|i_27t1fuTKUg(iTnz!lV79t z&ZV@$xr#PAgA{UZp-s*(UFy7+nw%NB%td*GE_c3%HaqX5W>+P(xYkmu>rQHOy^q>m z&(jvAjkYSc(l+HQ)SL15ir`2;CIBhYb|~0I4aHffF17B9qai z7Jo@@b97;BY$P!*V{Bn_b5&FY009610050w3w&E;75{%p(|dcHZGEiU(!IKFgRX7b zy;`;~9$N=eC}pf0CEKLjCb#X~lH780vu?aZL_`z?1hoTEP^st?m|a>ZFhJd4fTE~8 zoV*0V2coE`fSc!hNxHPl=KA}k=brET&VT!S=bUftV=y8KKd-S+J)MJFU#1qkYWuQxs#Esg@ z-fn+X@AaF^!{}VTG~Bs_&f3c|^j(QagcjmGx_=QLiFKPX!?4X*H?3|p>^RAy^?zk` zOX`;S!^YOiD^v&pt<6f&LlVhGHz?C$z*TMtK!`aWCl?KSNB=2+a(BawNF6yymM zZPvHy^$|VRU4LHZW+UV%$QPI?R}+pIsSjDPP{Ov2m{Z?GWO`@BP@odwRy{H=Vukd` z2D3LMZDNZgGp%~YPZlWTGhs9CtbaCbfzk|So1F>v)OtHIN)$7k`t@s@iKv*5k<>1; zJ7G(Y3M^>JVMu!lHf0|r`h2IyjL%z0#^)kRThHfKm@(5iRlu$qt9LFequ#Mt9=DDQ zF7TolB?a)H%nJ>L{591JZ|ckE#RN>`ujyV)LJ@z>6mVBDT`6X3P*5R|M}Mv-|4pu1 z&@w)TWSuwL2nF*5CUhIl8l3}~idIpEFk`6~C*VX4^HC)*^)SmehjVUK!i-Ym15o0oAcm zr@T0c1=L`fz}%b>93w@#E`PuZabB8n%1CmQWp9^`-QdM3IF)x#BX!LFd1=_h zg@%0BjI%YIg>wW>JWe77=aROmgJ)S$a=>|D>_qQ3{mn>7@Hz`JWe9UB&}Q|6%2Io?ds0$v|N{2 zfl10_SGBeteQ`r$TYnp)@9`aRmv10wdb%Y;Pe7@^QvOd6L)2z&>o#uoaZ`1rb_d@x-yX zYQe_wwcs*b&hoCvL_=~}Y1_JFuiz6JuHri2*2Cd(;Yb%DzJFC$<5PU@8p?sOx+u7o z;$b=lxzVB|nleV)=JY!0aUDKeh|l19GU&*C1)ry+p#IuwY;(N$0!}T&4frAz$CzBW z33GYjX7Xdi>h3m3(DYn)s6}p)v|Dg%0XhE5Wc6xJA4;o=aHdpLsQ4zF5T4!%gV{M4%zZ)j6K}`F;F=^}Cm{ zIT5?m>}}!Zf3$2KHH--F$1W!PVXA4Q>gVp1o=IA(i+{@-qhgp4+n_4Dl}beA(R)Sa zrg-rnMwrV(WNk+3szg_pVH@GKhA#WnBbmfWYm`}kG)E9*lXzUiWB3tQh|%2?O?v+) z8unl>B`;N_a0|_3UOd6Z?7@=)o>0V!)5=raruX1!N|Gik5eZksET^K2uYUz)YflB0 z1InhKQhz$;$e~Qg6g*2FiR)d)dYh`utg3N^V!Stgf#)>*9KRgbXfxOGn2q+=oYT(> zh&mN7l5u2m+=>NA$=_rufTUd4V_SWxA@H}{Eet1J)V+9xz3?(#C7rE!U5sV}6|YmX z<(S(R8Rk&gIpMfQ08;4Fb zwIp8!e<6h&tHtW0I!@F1VTH1JEYn^5+9di{Q2uwRnMULWOVRYL1h*PSEUIZ!*Qbjqb zp?`r!Z!(MZam5WBs?1aD`>v=P}jE|F5B6rriGvE6{;xU zlN#NTBeoUcWf$nCb0!yVnx2}yVxpL&i3y^J@;!5Dnq$3*bni~zIZ;AwfHcqAp-D4q z49(ggB_@k9<~fBNRF(5c%BE?WC>6djZGVasi|UTUBByI&hMbn?$LGgXF^f7um)sRt zks}p19`)XgWlZW-h&jYtRI-3&V{(n|2pO!1`Ba|MZ>QAK_*pr%O1w@KRhl?KETASq z^9@Z^iC)JmYN&|NYOO%^;UW?@oC;%`5lT3G3oGbaMa?+B1>;;)=)#IR7E6pBqkq-t zlas7iD2E5Jh^E!U($^+pju|!1*cLK+Qu2TBSSijHtOkpm?a}HrkrGLtjL=Cn$OGHpeNd_!xYUK{}|z{ zGpAF8o9Nw4qdJ%5)T9=#qSJI@TI(QY1oDV1;2FfM zU?9IXiJtaBL<5SuQO$2G@JsG$nc5Gn*6+(p;u4=H31K9Y6l z9iKXatAYV-2%mhUvC!Sf=U%ek|dyF9|$KE(vI)|5w-{KCPpqe+ajcnJHf+@r_}8t9{_mlcZn%AO@1y z>GvtaxQ`VZBr^u^QhQlJ{+-zCQ}*GpT6roMP$WBr9h}4vd-s71$A5WGx;Q+=90 z9SVQyxd`~`e6VNsnFMEx3J zs)YW%nB&XK_{P&wD(c5&L2Y$SQY>zlmnBT#{8s;cBVuWgGk-`l?BVZJ981MXlJ7nw6erUZevgDZX@+GuhzBHm9Q!2PPXiglm+*>&uj5S#-^RNVejo%x z>am)H6U0;r%YVfj2`j~N2~QDgC5(z*@jPLdi0dT0Ufdwzjp8;5Zx_Q7J|v!y@Cosh zH~@%e#Iq7VAYNzay4rQU>jQC`gzt-$uA5x9NSq<#x4QZz&XDojUH3?wA>%t;_ez{0 z|4?J@iD($F)>pZ-$DysbYQvjc5wRYIsFACWt5|x*9Oa zbqrm|4fD+e5D0MlQ>^==;?z1t)y#`a=>oLuJA$;ylQjS|zargfKP)i30BAAgL z!wUcax&alF@ntiUxO^pl%Sr<=6o&s;wOYq&y=~l$f>t{YUKaI&8$lElK|yet&YVu8 znG4CZE<_*1w{RhX3m?FT63jfPOZfrNU?oL~3OAR-Fi@aE7o+>=3wY?R# zHkmcepZRuIYZh!rdg}#}L@FUNDv4O_k+FOh>&>;zwWb$R&%Gak5U55y zn#hlg3^n<;MrIiq|2VO^)Oztd9~2iWV^Co7TK1%`WfJ*kt!oPMGKSgjYy2pU*{^!i zXH@DJ{<|!+rP5u0L#Je|jF^}@`E|L-i`z0ayr&YC?+Yv}|26mz*DnQ1%k@hK4yqND zG2vhYqoWu?%|QiYMKvujc)+iOb#BW1GXtm<6j!x^;Vd{DjPp9n-8@Phz{JYc)e9z9 zpE1>V#>^AYM@xN+(kD<$2MDSJlS*m<008F!002-+0|XS4S$;x)rC1AmQ|EO*2T7KE z5f9rKA|8oiaF8X-<`FQMgw!@9F&2+v2vr~<*V46pLAuwy_X=>b&~78!+HULGrVnTv zvTTd9E$zw%vQt{vtI3vi?YedCp54}D-PXOj?)^yV|9p~+Y|EtE`0>-Z-}jyGeCIpo ze;%J-{m!Sa5z!`p&M{p$d-mYACt8N|%t^z_wsf~-hFjWOGIoB<%;}zKTgkkgH8AfO zIYW01ERO1KGBavqPP&D>+tNL(=Uk(`Wo#sw*T<5k$S{Vs-<=)W3|?njW&dy?m&1fR zswX!Ji#1|ehT)jj2xdkp{#w{%Gr9BX-zH0UFBu4t~AbMas}5srS$5_WDM8ct6RD{<=*PersWxqrRO?_ z!PuFzGbeF>;m+OjZin!?drZspb}~H@yM?~D)Uej#0o@zzaa|)nl*9J6hnRe^wui#B zn3if3qz0xP{|~)`)TGfeY6w%97S&ULmWL@s_40LFm^4}_Un}8GUF}-8VZ#Qdk(w7Q zJ9I}%In%T|<*@QdxpvSK)a+=x#pZQo+9xm--;Nl6USCdkU8bg3Ta_#|xg$)s(;Z@k zC{wul*dVpQA;uZg^;~7Jhr_gvS~XfrcQG|9)`Dl|I{WNg4tN8G-5|x7>W%!EH=e>i zrpC&Nic{urV4M;fwNX1*bC{9Ca6&_@?dZ&VhpB@)86w($I+GeAZ^lUv$$`e z>FzRr9VlTt<6+uT(sC=b*0X?gb1XGGICjWww$XOk-_5jmrj9|nhbd^fyX7Kb+DWYo z>0Y{z>9Gft$)i~?Ip$D7E*)Lp>aLGwZNrUPwinIVmZzImRJX>#R?^8d+>Yq(Gh-MS z*{EknhfOORHO}Z6FE<|DIL$ZS5u%#*te*jzvyt#QM-Tw&Tx z_iMC^9$;GkJt8Pb{Y-0PZ;D72P6Hb4p%jzfH6J$=X!^snkM@iC4j`)MHDB3kE{tPG z^Z!@d9mJ79yLi06ZLUSabcha%Dv|(l=1_>l)SkU0sFxRdy<>BYz+|{xJt3OMyBAH-gxI;q%UN=UFL`(K{A5CgSi!2 ztPI#U);P25huSh=8k0_FG)gC#R#a<$*Kds#Jcuy#e30^z9cC(D!kev;MPrrhfVCOh zP>}riLIw!Lj`j)^Jt}DA(kVn!VJxeA&^d^u{(cd6hQ>vc$HAV-*{%_$@00rA(vx7% zDt#dkS*kZo@1S=I@Ap@~z5X2)#?AAEtLpDM#PnjfUw*X}`qqk6}unV&Q(PV|{aXmlc7K_tH;@F7IPX&hMdl zc78y1K8-Ts*%e)v#cHbhA^NcJenhIq+Y9K7!gN70>@NDK7!W;_2b+Gpl$@VoS_16! z47ql$;2Bb(!}P33SW7?2Jo=D-z}n2HHY{on*9w5>unr_-?`*YNtu#8Nnml=ZJUXhM zGNMC?!%nU{+UmAim3WV)?2?U2)6;pf(xY~(+yXJto{R^d%vlK0Pobx~ z>CJ-$%QN!^x*cOo;yhxuA z$A2baLtYh2kgfu=cmV9R9b(CgXu@40$OfO4Fe2!j0=+4`JqDt@_a&&n}=%u19 zDdsoP&$;@r3N4Yvbl3M3{7~|@=<^!AO1~{9ql>D`pG1|#U4N$(yI(@WsW_pxFgz>~ zwZGt1W!~Rox~DpMGIhpMwM|#K%*;A~ z`}{HeiMaMp;gwo{2L|cS0Jqa9PcZmjNEP^V`nvo)DZ7yUH3dZpwOp8fk$y=K`d8@F z`s_k3t7^7lh=%BIBm*KI{8GL{^mj~*I{gp0>%7mIAbm^P5ql&|-qpFUn}KJ`RLH7W1rTC1m6D+#bBFqK@HP?3RM1s{;Rrw9PY0Q)>z|3vr5{$y>C@X<0WtsFReUx$})r82=zVt z@WE8oMN5l+DIBB9Y@6$zolh7dg>UCO#1eSEH3FZRLJn=sVb^eaMj$cFEkFg9*TVTS zn^e;wZmlGsIYNWH9->^sJBTWdp65Wh^x?@cZ%a9Ut1xgI$2E>|0;Yld)3$T+5Q>Uj zKwjkjFekaA9y)cRR^XiS(bF9Mn6zWOkvEBq&5)s#(X(ZaFmEX>AvLXt%^UN)O?bA$ znIq~95BE-$S}5Lu77B~^%y?@dvXxs=&T9H9MJ?X5q=VaF53KaW6KQuJm?} zQy4>k`3>_fWJ;{;4v_p?N`#ZpBaAo*R(W9>)|@|=%#Mh4yNqGIkn<|z1GBc{{C?ge zb{v@b_ly6npdjx>&8xH~c0V<_yKIG16`Pi7wOI;MOuG>v?)ps zOwfIaB5h0$yh3#oH@+3WN}JQENxJ*mPTy94vwzKVv?ku{-<0kS6shYxX^99vb{sk9 z=ZPDyB?jUz(Oa0#Q`G-i3Z@5q?SqYfz40k}aNsKKNvAkINqZ;g!{7n}{1>a)2IsJi z#eFZ)kp<{=)?$8bI-QC)rYGrPD0`k_33dLRiK}!>6nq5Z$I_{nX-VRm|5>U}_%=_7$iOg6EUB}ZyffngN+Z+0zA%qbPC3LFRc6wW+!0%*Wj0bZ^N87 zz*kQh%9f2;ktpO~b#H!P*kgt`L}zgX;adTZS?)R>F} ze9d)5dSU|BrVljNEx0IV|5`HQFVfi}{Q&l#!~RKn%C|Mp97tY!jyjUffz1tcZ!o+3QM+>##sGt)wlu*=m$$p>6oxGHtKL;0VB8 z?}NQw#dp25-(__6V#No#HLm=X1o;JV(>mY1s}s1*YVmA5v3e66J4Kg&E>OVt;<*LT z{dI6&e6b8%G)rY0Z!Jg1b64p~I=vTa7K`+0!QFMi-8p>x8Ur$)OYalOFnzK}zcEE$ zP{8@(d0HZu`rRWFH@-MQN86KE>C3|Tm9%(flD--VOwu0}>1*Bo2LCL7eUz3|{Po(z zjc+E?WhcFk{}VUfIS|KxNv~gTFVbILlAr5{Pz~|gt1Prk(G?U`w1rv~jnP&`x6@8V zcheq4Q?y^vgLF*MV{{zWCDLhF(NVG$Ezo-um0#g|KYc{el3z6V1K7ms0_2WwRBVX3 zpvo8r675s;*O$`!;_1s25u_!KH-2-H{uT%Uh8q7KKkeo3f5Z=epj4!PDYGVzH~yPq z{qO0O>o|+xpE_i3$yVP%gs2l20HBh&_;PzZtwMPqQDk4~L}0tfu;d4uxUM8hx$5GP z@d7%rg(9Y8!9@i+9&2l=3<|?le_)g9Z)PQ5ESCo?x4680QstTl-CH_5m)j* zEpfqj7I|G0-*voy$nBSTLy_-Rzg;QYNszIUs?zAIGUr9}em3I$Fhb*w9-ci~gV$1;8(U;p&SDZE^3_CNLX1zM3@E|W%A=rX4;wOlLm z!AP*(*Bl0rL_(L=6`Hv5>_8;g?VljGOuMhr8|fxKG|7j5uO}kGNFaHcA8O*a;rbQW zArFQUVyZaIdGyF7WbZ8lvQ6v;yEgG7uqYA&H#G5ad?wWuhnhHBvUGfsN3K^(ewji7 z_p=ede8DTP$FEa_M(6|&v8m{z@NJ&XsIgEPpP?ss9mYaeWBd+!UX6vy_yzie8Vi;2 zC+U(J3ze}`M6Zm6%J^mayc!FY@z?1aYAjU7Z=eB`u~3ZJ#|<)8RL1SxrN%;KoZ+XH zo~5{G1p40!tUgK$I7L3rV9Y8@Eg;`_0Z>Z^2tPilXQ&PU0NNXqffF2)&zm)W5$HlG zLb(YR6xUl@SXRIfl^PS%#Dt_#V+}^WO}B^b!d}W=FviFa;a8~9n)tyV;Eyt%p+!jA zhTZJ!?wR*F@0pyLKYxGy2CxZJ;Pm9=P3eo7{ZNZ;Onmu^IBddoIE*)klJgX&KnH|krg4NK?ze&V_uh?>&a zP!ivEe4^0tZH^vO7_k?}OY2+fC8I&d`sYyyfpjfMLW^EFE(t6Kp>5isY`A282xY59 zp;--Vn?eo40#gU_SemZ%ZSz(AfUHBA?q$_7!?>fN*s!R1;m~}>Z#*9 z@iT$p{9a*KU?^YM%^-v8X=s?tAdT@cj3CQ1ZeR<{<*OHYYq45KS#g(YsC|rL@-)mb z6C~_2*Fl`Xe9iipDUx0}gBw^F!#oxRX8H&;rKng?FLAWR*AF%fzcw_*UWA;GSv_7~?0an#|{7gugSG;L+f}d0gWgNEhG# z#8mDFroTd6S@3_ByLfoa7dS#aDmKFF5N13d^#7};eeNEkc%>ZQ1uRsC#Oj~rmT(r=I1M|3AjuI zqdHu0T zD!X(J^H`&8LxaKoLaarog-OHPc!9ENLyJM{Tp3keFgWbp`~*}H)?3g1yZ(%U*Xz%^ z;6tY}18vx0(72*6zzCXT>tb|#*rvWq?ts)IZ63ct_w_eWgDvpB0Z>Z^2yr0v!TT@%u$ScDfRrzVR=Rjj3cjDj)fWv?wQanp7LL)Me^LS6Ez zBMR%1w^~9L%8&g(G;d3f4uLKFIqs5JYKSllKY!$Fyx?%RURbI;6jq>Nh+(uYf`e8J z=hO2&ZQCoTU^AKRV>_^&!W{P-3%oVMaQqOcL1!4cYP)vuF~dMQb1#lKj_HWu4TgBX zPYuJQYWv&8km~(7Mlh=5I8HE}*mJ#?mxhx%#+9e>eorO0)eg#m6uhb7G^KSg`Cbxl z>3>MzZH9>B@hZcqJ*7VTp6)w1tHLB11}(?)MI0$rLIUS0;Z^atECLmzzb6FE#PKdB zl;L{}$M%UdWEi4$AS4ew$#8O?ZRr(G4syuHkcGi8a#*gRz@QP|7R93=j*A$L;eA}9 zid+JyWjkK`Mod00;{&DlA!QJFR3&ljpnvW*O1ec{(V=0QA?ELLVls-W``ELsu7M`3 z`vI4MzhVcpJ!9#^KGjq|#b-J`!F7h${dUEFmBLuMbYu>nV^(S3q+UC;7s@e_qZG#+ zN=oo0o$G1>6Y0a{9@&9;EU2+8k|7P6p?HMh|8#X5UnwpxGbHw;%WXHXn_~8nC4XA# zlF)ExhoOC?U2oe?RjuMZTCZ6tJ(*l@2sf$dNaAE`m*&YDvG!UNU;j%>dvw09V+G$( zlhoq7L}=qb+OaPSD&;$TuUtG(4;py(h)8|Nord(*d1ZH-Dmw1MqU&RKiI)26r-hE( zpqnmo4uixe^`qeONY!@+ezJoVIjBR2t_q>a7(_HA_R2KjdJ z4$g!)7ve&Q^b1Tf+{(Vd6vInCd>i725IomG^(Ez(z$_W8qlUAX z=)EV9!3JfWLB4n1z)!ums&0UuuVLUHP)i30*5f6tngaj;P6m_lWiylKq$GcnZBG+H z5XWbs&{B`m77(N=a)^C^QlWt5L69ev1jV$1fsl}GyS*M<@0z=7Ni_07{0zR(#Ky!I zegHp|akh7-r9Etuo7tWJZ+3TP_t)9tdnZl^SFj#-4MzT<5g$turF~pS;RSY&GR;6TxR+BQ%!Sc%s#e5pI zddA^PHATS*qfC=?T%t;{%9t*z63*6{Zs1xtpP5b1W|Bo#7eD%d5b)=;nqFYfWrY!l zYPyt?bXrtc>QblGD$`T%EW53#C0VNJ%{_a;Lm+rg>olcMRZ1P?&sl%L^g=HIp(q=s zF6V1pM_wA>e&4 zSFKFkv$O$R@lw+--pUoG)l33pkHAQsDsqvUY^NpBp{`)`gh20_tjgv(f$muRNPr<2 z_JbF0<5pf|H+S}T&klccyVEJzjfX;ii{gXc>Rz+aoXoMS_gkt#9GFVSc4EoUbnRu zdxi}T8J!VNY>yvm456c&+K)6|fHC7g5>M=er}1p#YlAsFmlt0+z98Nb^a#HXO)J>8 z-?jnU4%il(-?Z3zv_bUnrr~!BM`_GT&_)x-$ET;?VI+K~0g>>%28?rWf_sypXs`iO zo(4p}0{?{|262BoK^VI5573*KZosq?pMj(u5257?^d!DPuAi@Th}7gH1QwZY4YKhJvF&_&(aG{2f_vp=qMIO|m#tSdz%|R8Gc;Utz z@wLng59W%me-#Yg;y7GbMM3gW*)R>;b)Oga;{gv=!%rM2nv^Jn=S2VuJY?k|hIu-d z61xbdYX@|Np|-xEqdtOA{0lQi%I2Xn$>7x@1^f#e;un&k>6sg(Lrg$<}J}|+Kmo|Wz$Sgt$NYr*zac8dQX(}&BDn(!AUQ+>Ooqse`X&A@vift|13>wnB(To{mG2=(8 zRHUH^wL%*+oM9$D;}vy|GxBnqZ%}Od41c>4ZU+lU z*aODSBv_qxv9WnfAqBUT=-zlg5c+m;jCj-Xi&I0dM(#N~%GN|YHrjEb$EmZjR`IO1 z`XA{Y=8{ebIzECYo(bRZ*$S&KZDXFb%jz18T?gq(wc8+*iHy_V5 zG;vBx9tv?>J?XCK)-;j(3`j+^O&6%vkrNKekb^}7ExQaK#5agug+tcIfTc#OV&XxIR7|mxq#2x)%)5tR-;1J zE_vD&vxWVezwkzGgj*f^VD!x_ce}8yTCB)8{qV@Rtama^U&<}?u$1h(ox8V>(bcw& z>UKkNom%~}N>(+>=omYt67~dZ-oM$ih;{4*&tc50o*BES^N^8se2kIm;DoeeD(t?&>Xo1ElGfkpG&@P!+LNk6>C?y44$scc%7w&cnAGnbGr&sAi^ULJB zjPun>hf~TZx>zsO=4VCE>p7LSP4L2U1;yiLAR_DA%Y^D~Ev zrb+5YPrZQ9;UAz$XqzrtQ4ydmuG-+d5xyPmg6dVe0yN}T0S!K2MZ?1mfiffbr(p@I zR}IkQN?XvHt$#KO{$tg^>6G?@IupRX#t;+$+?W!kBSvaliA7pL$0BKcF!UR$rf4LYVJFgR zzC>v?HUM*ugUvn!i*_WWxMThiv<0|@7;HMRRf86Iv~`vgtZ2-z3P$XL5$y>P`BoU* zY`q)<7upNC5WsLY1}Zr$44k*@__XJJY#~U8jzlC3oEPB#6cjq3iC4J*Q;mnUmBPcC zkaBV@1n3!ZCct&HKO4g0^(a@q4d6=7uz_4c5CazoP|;;>c33&MGB5-;5I}t{np_tE z)FQPfVJRjMups~*ZfJfz1*Iq)0ItUg>mjCd;$cv5{a5G_g$r0HUHBa#TE7PlzE#)* zOic$9(?Jj#0c3yw5o7^eu?5z~CP2^qAmjn&cOgJo9sJqnPPQHZG8IzYFA?l7QR$NOkNd-Rj7zJ!@78Wc@jZZ65>kXOc(Z`dxJT^mz`h_ zni8Od6Bv}&zy$ar3mgyP&zqErL78~~OF91thak3uXs1r9!T(#DF~qLK=e+PfL~K=h r1`0QNfccvgf>;njKhA^zKy!_#L6FRI>Tmx6$J*$`DwPx0= z>0W@Geub=eKm{^SO(BM{p+oNmkXuFgJY7?f*3r%C|pHrnjTh7-ddAF7&H%#N~P zyD#Xi+BB8C9~I7j$N#iPjHkK9zr*ZbvX)iIrtpp*q%?=e;_u9krCnT3RE5@VU37Ax zXDEQmOxms2K+#8&jije%9}!0tl?vvOaz4s4?95IjF`(He$#$2@GX zIet3+(XH!bVa7+=)m?HZ!}BM;$zK_53LR1Ymjajl)N{Pt0#_n<9DVX(h+)4Ye0&M0m##;Yk!M(9DF`Z8 zhvZWAcd5U7Jt)_#5&t0k?{^%)oymcP&~<>C2C4vc^iNc$uj?%7I5E4}D@aau6`M^O z`$#tu8iRU}eb4Tz!j5Q;K3QALb9gVLuW*6dl$c!XB5#84fS#zINU4~{)000wtb6!( zCO==_F5td>Wn$~v*$6NWE^=}Yl3fMy)SVjWi!bEJXzFjK#~V5Ff%4lJnfipqNzZ_r zjDNfeu6%1iWm7J069AYRI~>BUuPNdvT6EUaHQc@!D5HYA>~Jvo7NDpA*XuvO&wzZI+Q!vh{NuNjdUe5Q0HS3m!p z(WsYHmTBWKU;07I6Z9__;V+xX`H@f4wS+Y=2=^u% zSVUQRI54~>DTs+@;>~!Kn9_wH&olU=Cs2Zrkb}yK9pbsL;y9#qd2@xe+z^0cd@TEo zF;crjuG-Q&Zu@x60hw)wNa-&65+TF0lwny{ZO8*_hO$M9v^Oy^S#7ClKm8tDTxpBXQ{*i3gFcn$*Rr1hmi+nK5xJ(F2gA@FiZB8VTT>bG(>w|Zozw%Yf} z$}k+s7lm@7uqWAN8>k%^R80B-9p;>MC133(frw2?4PI^}k|$rK1L`0H}b3DP9)e;GbD^61&xKq`?To(DF)h zIBbP@&*qowowe27M$UpMcrWCiu%fo1$+r7w*RuGktr+NOOf6%=Wlusg{Him;PfvNI zy%0iDA$tpZdlR>CMdLM~#Fhp}J#LG>ddS{We<61$yVnqATN4D;Yc;j{X~~63q$-S- z_gSD`ts5&AlFR~o&^Ff=UnDvj3G)qynf6P#iGv@BQ@EG&b^`glTM7DjD(b~061E2O zIx8y+bDFZjIU#9eAKraUehJgf<^yyXK3&-%opV5;rJu7M^K}r*1)`OIpElj%xji$c z{ZD)(G;S(E%78P=2ncqOeP@YW@uyuslNi^fu3H^X!t*B7%RN`dK zaVzI+(p-TVRhU_^2HqguK*kJOW$0mf`5h@TxP6>t=a@P{v8)MuNZ4TPiX#sCmUPn@ z%@FO#FB;ciUf#Eq{XQY#tHfp|P(7-mSmvd)hnQnQ#tVe> zo6oZ8_J0PR;#AIOb%tF zV*9NB!GbRvx33>P=PEK3&?~)ritIRSsd5H{uW>*5q zHnB^0bMuD;p18VM?!D^l{Qhhb@Cqks6LL@OqfEgE!n1pk&mawYA(5W1WR}W*(>-3d z6l-t*F^4;>a!c@rpUoME#z162l|MYtSOWcM+&Mn+8J$3vvuiKa|CEg=}GSFrVog-_+8gO(pe_Fjd zopb{MUS&VYFZ}AWuq+cV3W2Eec!OrxF%RfFVx0!Gl`bkW1fIbhc`Z3U4$6wFpG%^i za|(M{l2@M`a(pvvi{tW*CB~d%;v9csEbRvsl|9Y<6K*1Vm}YN^G0fa5-_FTzfx;cw z`b;n$bmJLA(jd)9-)Je5w4{-Q5qx1@5%pa^Q15}wXl{{@mLJ8o4UplcFb>AhOpa;( zSJ|nQ!>a&sQ)d_ENTf z6HlAQjguF+zB06KPwDEvvmwwQpK05DVlURe1U{g*R9$J_u>LS2r|TM1=AjleM4s%3 zcPOa>e(Wr4WCV7FSqJjph_a-(G&z)f{jU^-o~ z5j)e*jrI#>%Sa%WAV)%^oe)q9-@j5YJMqagypEYh$YWN+(Vv9|``w;Ju& z>@}m+U!~!}KRk|vuYUX09l!rRc>09j<-@`5@_)Jg(Q+JC`4nfg7#Xqv z(PIf&t{v1IwB)i$J8W|40|TlpHB_k!7d`b`iOon)l)o-igfv0t79n?ZFls$x5gF;W zg&USYPTh`%)ectXbNi?JyF(HqGI$_jLH~`kRRL{s-(Z^~V9ZDr$|X`@$3s`;TLsxl zPs>|scKpDI551T}dQI8RbVK>sJkj#-qkOL|xnMyK{}KU4myq}55%^kLd$l&aX-A;5 z>manmRb7C(@vhcj)VGWoT(Me_-^D(%x+^Q}ZPy0Zyj1>+1Vy_%t@DlRS)J173km_x zn339mwPR8zRJ9W0l2w9DGR1F(6X!|k*+tERniq5~CobZU2lLXxq=)k38q}%cANT7N z1Hz@3e8dD2wzV>Vejp-G9TBR5jD$2&7#EgckjB7Aas{?5*5*%wju?x{?lBTC+Ag)6 z`D+N0dk;&YL7;Gw=@?dk5SzdF;8uL82LXvpC$e26E}4HKPek?98zCPWgs&Z`&b<45HQmKYh^C?U+yyqK#!PEVfXa zv+*nP2ziSk22jHwm@oIYRL@zvYe1C(sq8!6a2bM^VVmm7v;wE}Tg_ie$q>7N9C)g5 zKr2eZ`ejnpbxLSX)xc&B`(${%cHnSASwpNvtDKs+QAL+9l9Bfk-GrEGnV$heREp!9 zAYM@nba)f*aGzG8zE=H4rD2uplA{CDS5aIx`qT`rUSPqGY6@x_f}Hw9V?n3oNfYdr zQ08`c!7WZ0ErZxg#8Rh1RaHPrtP(eok{ekKzm8k9_6QRrpO(FSHMN~D>dYH^*o=my zaCXF+?+ecczjA!cOhdn;8@t77;f}(?9e&{+lE&NdjWkJ#(_a0wyFzyJB?=_GrH)}(PVnhvmu|IVHT3W8bNTSa>a{p`N@^;_ zoAx<0y64$`U=T~jgo-${@<7M|A9qy+3I~>OBxS4K$funriZGC78(0QC*d!PAKhl-| z21XR$UKsWflOk>|joXB_-X`tV1XB1i=3$Z0QbyIhZy)ZcE9klkW5wi%n)G}ljxA4k zU@`TvMSbF#F>~=neWUPL-Pz3==<|V4T_7i#p6Hc3<)}TD_SDGRcsZaKrzLbYb|ShaAmZWp^V-FJAI5iL=BdbVF02+0wg)}SLG zb3cji74_Bp>VF&xzi%8%zYt9yI(gnIt*`1kN9r@s)5T?K5fNd0D8I)k8)SW}1d#YloEP3Z>95S}ACcXkp%jaxb*Qo77Cv z1t{8i@6(@`{szPFmoSyoaO@hQGJQ4{u>|pfA-;-DYuiZAT$UDbS1;;BF>dSQu72e%dN>|@Okf8Qe@aHQZ{lNJYf_K>%V%zBDq?pQ? zvhR=Vx_UvIwXC`ZifxLRv-2sYmc6{ycr?98Qgd1n8R4@=@wJ)szOOSn?Z;{_z%T#N zu$cdFuQ411ga8@@gqk80co|y=9G6E0rXxoNrc$s;57IV;dZSq(mLuVP$5d8AkK@Gi z^I6&UN`3UU6U4MQ412~8EQp>S#0`cJO}T0EghfG+$hzOm{hYbV&JDPnG1&n^=z+$r z&0Mae4|2Bmm*W##dI}Akx=Rg0cD#g1IzzZVe&HOVMbP?EomfQ5mM&o|t1RA829-896AsrqlPvVyTsS)*`r*Zgc69Mkra_o z7H~RW-yreA){*@SsB_zLP1^wBDq9Hc7ttNR+aH>LJ+(KjMBiseCcz))wyR5xPC0A5 z4$RsO3*jJw@a<)XFSD2^W)jgf5C?97uSpGW2x#a!^q;wmThAf`Mh8^iZgB?cZ<@MF z#3M>2`Z7R}G176S*=GI6E(W2+W?es)_^qmY_d2^S-A)&?+~zlyZh6AYg`&ofB$~f1 zU_I4I^pBWhG5ThjyWcI%p?sZ=A2WDBa88>IZoBI5=VO(np|@8_;bDANHni)`ZW)3 z7n;M_1A#PI|9V*|or?iHx$*M%B&u^IiDKQi)CMfk@J*NhQp}#bMo|$QMCl0BJW~OP zV|{`k6%{eYmBq9S#SPX=3`-2dwL(GAicnIaz)?>@M_lh#OYc~8O0<2>XGxTFDFErO zNRC`5@d@fv=SI%AoU6cN4x8Y=FE>cPsBrK_^0KSTL@N1fuaIc#rYm1-Gu0jngG}`| zIb-9kaqmK1>6!YB#D^h$xVwR5_d%_eoqx_*S`Cxb(2%E&;4)7g;MqmE`!okrD;WQ7 zC0k+a;1h9{OlHZepiYsZtCLG66FqI59f)Yu83VJrN%3botGm> zK4wl@WaezwYI3?f)Rm{7C}BL@T@T6gc!f$&9|esF7v$%Xt1rA{%2EO)e}3lm=htqd z(=FU-b9f+sH}3OtUPj$gSRc^u;37x(2YbHendv#nouCpu@7a|6p>Gc zDFX=YsR%2WIvU{@k5H(aX9Lxq{2>uNl#dmZaUZywv0{156^~}=V0WV04u3oJ0TUP<{(hU=)uph2MYc) zNTrig%V<778rQd`1qXL_ft6jHS?X-?{Hx}{{phk<>Bk7CYB4}cIoJZTd;J4Od^$WE zTaNHnOSkmO^Qhs$Ij4HArHctndtK2p=Ysri+Icgu6 zMqVJDofP^tesW&FX-v;20$a!s0jYO#=yi(EY)niD%D*_TJ;lAjivWqEy^D)ye0?Zf zTh;7>JHmI`P!=0c6An_a4sne_SNAayTJLO{SxC?KrdJo}geWa?8~H_AG*p+o!aFR< z8;`+OmAO}93xnf~?bqlZ7iJtrrk`h`B_i|o`9X<+266@9F zj+46dPeM?D`QJcuN+Sn!0kESJ^O{gQE!?W}Jv8no<~!qHCh9OtgepWSs2gA`LbZ-v zS{^K)8Le%XYe)m(|AuOokUW2RFOPA9w|9XjeILG>5}KK;+sMj;xF*O&cn4D}0 z&{4LL*^dkXe{1q|6xhvd@Op0P1;Cl3ayjgRv=%R{M+cwvq>=PfVJ=l<&`l%KR+z7J z*a&!7G_f3E6@_%_90GK=L|y{sWdN?U$-9I3r}txa?~`&EuP+8Rd?RuN(}f3P_|&y9 z%3r2hH58vz#A<*Wf<}?rzOx{lCYZ&}$nFImuE0%tt~Wd%DcsBMG3c)IN14Bl2gXou zLXrjTP2=){Ur0NOI-Q57Sxx`;5)0zDtJ+=}`i0lUCbTtb9r}KKfHLm-rtIRb^&7xp z;#wHBGPleAVHbL>dDlHv7a14YaI;Ua9PAr3F>$8o7wN`oMepMK_18iP+{pQQh_W72 zR*sNJ=m=8azTb4^O*dro&VO<1T<`}Je1U)ng8ctdqa@G;QyfcJGqI#iE<~qEyK2Ke zq!GP_rkGn~b+F9-dj>%u`v3>i#-*+`u5sMQe|q>*aY;1ICj>CX->2XPYLW`fX>%$3 zH2vf4VmCC7}4NbY}P6d zu44{&7^u$X3hC52av`?JwX;Zkd8SiLKG+Rs&?Vo{=$E@gxXObeo_FR`guc>bNM8Iq za}YHGxg6(a>Q$$^!e>Y=ARX3Xw5cnEYY;GjgWN$o1)}#`!9f{lA?VCKaBc0BRhVf< z4maF+D#|r5E3P6Nt9KoZ>@xFMaXj}yjlf`#1F}=Kap5Bd^sN6B|5gd=kHTA;?uPiQ+$8%GyD}AGl_?dH($UzZK}tlYwg8Wc4lYzS@m!S zXGAU;nmEDcYZVa$D#5SOHm=eeR)E!Y`(AK=^>Gkl(emy8uDXEU&{hP-T>s zlVqwOdag2$@KJe*m(h_7v&ABXH<}!s#C({;%8N&Q_pGwf6&3Y&Fu=IoN_sjAy4!Nu3fnh&WYbj?wl9I=?< z5k`zi>fryiPG33*3CRByy!HQS)NYJoz@!-!LU#1wk#H)mM9lnO(G!)C#p8|pc}<~} zaOkNF(%|Q=T3cc@1;D2M|wbokr5PmIk{-eI+cIhUC7aF^)y~NO>Duk4DlwiPm4Cm28apZV{Bo zSbw(|fUkG+t5p2L<)zIDvi1*U5TYU+4zcTEGXhe}YPCkcbh@9%UX7vk@Ep1B*%?vW-M zRN4Tt?~af-MthbS(w4mz#Zd_3wko{iqBigylZdgPUdBFqktYy5}JY8%_ynNG2 zhaF&;YGu4^&Ie(p{^!1o!+nnGI3v=}3pLW27t#fO<{Lh!650QXfB92UrV4Dz>;uFa zM@)0U#?oDE@$7^rV8jDZu@eyf+bl#yw{LYbA-&CQZWRuXhCL&H&K8^Qqy5*DOfyRp zP}x2IL!oawmk9%5%j$yY(>Hd@<^GPQLBI+C8BO9eBmS_3R%yax z>i6uEpQ&!tDNK4&;zC{67Fi%^^>WI6(W?HWMUqUiSuN!|vLUY|g9eLa1#mtoAEje7 zad+MfuloF2*x6smb+TDcS*b$g40<9z&)WHR)#)AjH24J8`8`Vf@YCj8$yMa$#S3J4 z`8C)`Qk2CyQAS4+HmF<9OfC$BG)_1-8)p3bJNvyLMeukL_32^|#voTPORxMM8<_%r zT@>0)^7@D)3vgT7y8oMkJuo*a0wk|wS$?o&{an0pz}ez{DWjRQoB>jyoTE!7ZH>p1 ziPvATSm8`BJ|tyy#$vV47@jiT{KX6^;6$7stpE(g#0 z_kZXy0{wr3S0p7a*chDzoWKYRj%ULF8tDRL@q&k;c?b2V%eio@vJ?mltE7H4fT550{g`2xCm4Q+~S;d{L-&N>BQ~YZ= ze#bv-w*9sfJ`VCswjubwOM`3@NL4LKhHct7Xpy-^&~>d3y4{AVm$`BTGwq6jm8A@$ z&Im@X2D9c78}uao(jY|c7VU_1LY zX^o=PmyyYtrs%+4#(F$C(vM0eKogphn3v}wbUev6+ht~H!v)VivMe2bqn5VH*rq~< z)9kM`?vljRrEbn^{mJN-e7m z%7^&oMI>#Pw*KpwRt*^>$MKX*7Kz^3yF6|k6sx-2Su-aRR;E69s2SWd{#F$yZM1PkONXz7QcxN+3xBCs0ulYRrfv z6rnNBU1R|9Y~j-jOF^MfS-exHht=VD0~p$?nbeEt9e2`^f?gYfmnzU#1;TMfd^qBG zZ-(QjpvS>2MU$->;mJQT-LJOx7AcuM>F20>L7)~R%jD}XVTWZ%Fm$PSTb^Sp*7X6p z(Ud2Hk!IOqy3piYoztE`uuxAMvJF7<`>OZM7ZbDjpgeN|)D`3aDJFKJ?LR4b7-6-T z^!M%irSzp0=qjJIZtn|J_nNTlsI5eAEk{}{ZZ(}!hBnu38Hj(FqSb$+YDW&u_F`vM zxmMAUEJhSW!6wJOWXUCa;JH%cJLbO-$n{5jw>XDqY5&q4HGzBsoH_t`8Kt!6Hjh74 zvSk-YOZ)E2we_>59)QdQICQnwa1Ad1G<8}!Pi*S7(p}EZrXO-MTh*>GWH*M|>m*@! zpz6}a@GKQ?rvAA#UN8zGH!MM)&ct~cajgw8)I=$40ATlV2ktBQ2jR|jkH2um5y{}e zeja}}jKfv&3+X=vzAB?8uzO;)IL_OGoe^qT#ws&Px-<~9^6i}y<1wAPnwhbewODIy z(s8G&t!}6*m$Rs_TV=VkivK8`v&6NIMI3LGLB|HYrvW=i~js9|_o9n)t#?Ufr;{)sK)Tq=eg+T`YTZX6!fBU`6XNNvH=M!Zn z>LqCvuYXp&F;fxIp+3G+#o2$$WLv;OHMhbZm9dfXEsVWYGr45QssG;EcO(nWR%$9M z*DI6w=}3$V_*Gy0o*Ok)M#q)!1+X6c?G0&f3&j!c(RX#(c7Y=vt~dA#KLIde5Q!@2 z8gQS#0{0V-e^II>HJ29`5;vh47|9A29#KJoHYsGrF&vhiaizqG%hi`T!k-tVI3<{2 zrF_Gh+JRoZTM{*))J6P6tl+|of{C3gIAlbwGreyGIz7I#Jl-h3Sypf@-y!D~dK1+b~kOeB8F04%Cbo>-(f@?h83 zb>=|An3+^c5LLF)zp`vGscEMK<@9_lm}W(l9CwnrRE1EWNCI|E{=-BZJ}BSQe|R_# z4*{OXCj5to05F*V1~`-ZA0BD~Y_NijF<0xMxzw?mQ}Cq8NQ=MCk4ECq;ZT2ZsOC}- zO*>T9TRkuBB$O%BEL#Ao|NYj=Cn=s~cc#rM@B!6*K64_SmFojGowr;+GT-@WGc)3` zeU}Y3`F#D9h0s+u^?kfQ?S4-0W2XK&@!;r6lQ9NE*%zlySD9&E1q6{W<9$`}2Rj%x z@C9w^RNGo-*?8Az^dU|kVomKQnX;2I(+!m~n7fMjIVnVgYw+vdjukRx5FHGnRp0@_ zb$_z6DsyQsNO+mYrC*<)7kz~?e+Tr{3H{s2j??|X4^~{_ll;YY*U(jA$xSk0YkyvA zt}oPSiG>)GtE?u30CWa{1;=wS7qEm$4R+S|@C_tz2M~(r#v5?*iXD?u_q6)inbs2tMiQa)^eL#~}`fHThirV4@<4XcAG3t!-P1@1ldhg~{*?mNa}!+>2? zhW*T_i)8h>e@C=3bSrx`htQIf9TZ-lNaxje4(_TuIWx4FAr4xgB)Y8J|1?HEh#BST z-*x$5F^dzYSgxMK=3f%Nr-+k7lZfJI^O6nfAn7b{!{P_|gl|bX+!3xDJB5bRgBbrx zCv{wK<1g&H1Ahwv7tTIQA<;mlq8$fsB1Do ze=^?;k&cFhi^C^^SaBMt0C%>irS_D90K688kfWE%KbetgCqmB|-;R-g=Bvrpwto3M z*)edBJ0T|-%NU`FsMZyiMK#DBw&)fdeSGL>vCzw?u>+8MY# zK(52c1io^qDP!Z|x5z+tn87s)$wD+9kQ_p{Z{+*nOtm2Y0}8ijZ*EDK@zP!>ea{SlZ{()Zs+}DXqUk0y8w|w8E=AXW!$IdKDfRf8I^5pCn?#Miy|vJj!_zWfR-6W3l(OKIPc*9H#600&8M!@c@y2Do11* z6>qtMmN=i~P^&ghtl}l3@#%R_Ewbi=c()X-lcWHg*>Gjg7NzG!22Ii)&4La3H=9D8t~v7ejT8i zvf_XkdXU4?qQ2;#ktIpg!V-2{?9dppP+TNzn1T@}4&~M@Dl1<4s8!Pa-fObZP|;tc z)GA2ZUC1um0e99uJwTBj2qFu`T`P`uq}BBO%S2=n4H~@DyD~ICW<2z zyW}*zbH%fWAjHTPx$1y*@VdqT@et6MLN+(k8l5C$>_mUF^Ma&;E z%+n4DrK;X+to0&^7N-tBhJ!l=^Vxjr>FW8?I>;F7w=z$qYny;}aeOuC{R!q!hU${eF7(ZFxZ(D6sY+eV$ zr=Jc<>a__7Rp|IpCqr^Vg&V1rX6`ffH9)B$7WibreymOGVkQsXg7gTHqh$tX%luJg z-9gGFjl%va1W9xF29Ft*ZPgZ)zuCYB>r5)w384nbOg-uW%zpN2O!<+B0}ZC+Ct4rs zHvkMfNqcQ5JG6)Q)Z7rl02VUBzn)tGl{JIHN-x)y$+zE}DjfqEP63-|^E!P{$|I_l@ZNMF$ zhY2~8#eWmzH43wZcL?*mF4@dR=1NRJ@CQz0rQq>wI zeY`D;{Jpv;wEKQE`P!0v!F%fVox)P$WaLxjd;mf+fNO!ugDpy!PfD7tJlzr&XZB{m z%KEp|F?AQyR>GuokU|}qaz$Yu9;R+Ldc1`nZTuc|M5ElVdWL^<$CM1!q;67@cO3@Q zjB}}7!$n})PrQ_y$P@l9HK5+V#iG>L8%vWuKKgI zZ0FU^s<@tlaU_iFzwzD>o4lSvabVl`35?E zE@xP&>SZ#0=h?A<%0v{7QtyTHZC2>R_b+4?aQN508B59gr~GEe8U#|G68eF5G0L>6 z;3o`z%~{Gh@BDFILDv20sCtk|_p0ACQCd10$=e7@lpxr<>Ad&PCTRDy6UU}(qg~4C-~thtgbFLQ3PIpPO;F9 zI8X+;qU4Boh4~y;e*j@8*9Fo&0>c=B(!9U&b?V7#Z4bN=w?(7D&;9)Q816dXG?wSs z9s4$gNx}<^>t}jVSc(}wFsO5b6`|J9-Wb=5rYaoKt z9<|yDTBKRG;kWxG85{H*s+a7+)&{IE$)Chk7DHH{Z3(G9=rON=SBaiO|mr0#^IA$rCW*k6;j7P)4b9cSx*Lb{GknAG8B?wu4|c;0Q29Xl!7bNtQ~>qPr*KLj z5BfyJMGu^7*`Dw%_!cII@F&w1e=U2RZ# zfrpz4)GkGu)s2u4QX4L*Qrk>^_x}hA_=)q#)Xy)Oetpx+|?->NdckL|?fHv0OEgCa0*KD8~ zgNeR{(}ME+X!@c?Wm5|tm`6^ZO`17tO1Yzc4J8X@)ogdES3J<$cdtMoF*FWU)oU0y zLQ6d2e@XL!iu%&^wCOQ@mA&)NtV;U#^Oo@2&w4x(VPK%I*L`qPd3kHQdQ80Zk~^u=4 z8GLFZ2hW9`%~>zZ#q`ue`T}z;t}qWpP6n#(i5da3q(INZ$x++J!7CY3-#)x|zn^9m zfiesHlEBR-hKiVy6wh4y)y&*^ikZzLwMeFH@P7g&qjBc$>N&?Jvxj zKH13^8@?lv;1ipZmZ@sL1P00O0XDpix-AT$m?!9G2+5u8g)vZRdKQKol-J z2)K=k+=58{#vabMq`?)fZZ7Aou|;c7sm>=K2aih+0yLmHUT6tXW2tibI{ow-b>&-T zANk2g>nUo<_bIBK%BDc97;?tA@qTe~V&v8;{UvTsf>)%U?{{@3sB$&H(Iks=O=Z`j z?}OHI;)?5p;iAAB;FFYG?HZt<`==Ol4VYR?NoA5}u5TEM51RFkXNL2X6y5NsTF(_U zgJFxF`H-WOEawVIE3>WU(`8n!p@S|=b@60VhD#j4HhYDUZU?GuK2fOVaEatiD9tGQ zHi&WN%gpU?)@T6NnqIrxRE{&%_jiuQp>Y(E@?e+{)129!|9m&^Hu^)^;MqZ00W>)v zEa;(X3(NsgaK#|s@@S`r+ekOtq^^q@S1o(y*6I+pXe_=oiBn2RoPOu`td&?pLq+6j z{7xR&nq#Ns!C(ifJHJ@3neIU8CTvxsrqHM6%V<5_6IVHs)?@&({~PC%s1 zhew|7&X;quI_#j?W7~NK$$rz4#gD<< zrjWl?AM46Rgep#V9=sg4y$8jb$38*>zHDfxm^B>IhYmTe>8?D?ER6y`fu#|1OLBO zEt9wsFj>)MK@2NmX10l?!T`}}{LPAoevn%&u^P4^A0D9sySyNuiFW!s=f!iu4Hcmj z5WcstN~;jlfGQZvHAhVh20^ifd*rzDDfcGuOe%N3^=fXsJrql{B#E z&`e5)2>wOmA~|u#<~GF0Xu%)Z4XVmRwEweDP@7$`z<*$sKeJV9$~aO7es3vhYl zB1*wM?q}M8Z1sIW##Xx75F9q_><+quKbFyZ4K1-)T=yZQG!*Dx!f+zP^hmM)x`$jE{LhaD2k&}o6}0}3;zs_D3C$(tf!T@BwMcM+xBl3W^95y{_El0Rh3JuH6YG_=lK)^3r!J!M^f1ltaR``mDh$LJ-khhBZhGg*(&;guRL{6fAyz#QzHo3FN=7k`dhF1P^v8K}arP0e;tv+Fjb-WFTSB{tPq6)heL_V%^Y5)p#3Wc$w z{}%rhSDodZsB0=L@0{=Ab?NzW)%`Tp<@++h31L$|di@aw$GBAYo!i+Neue`F`KQw) zUg^#SKaeGXXot}!NKs7)y>_=Ce+U(A!df2q4oB2#Mj6^v-$m z8~UAV^wrn^*SgsJo7t}iCj)3?@OLzHuH+$tfqZG^dOxXX!)NsCzt$467gsQaSI=(G zsFdY$tmdX({Q(! z$Uy!x6a&tEbs`q;ao*>wV|Cdf^1?s2Zle1&^# zrIwpFo`J9a!S|%h&N3Rt*dut0B0d%ko268U59S9u8JY2rF$D2NgRZ-x4m#|TTS*uR z^CDkYy-=|MTtm7I*GlRe%C7X{cig(@V?ZVbf^j~1s)(V4d=IY z`@=9bx3s<8w;IL`RVSfNHxwc%+HXLt?gv809VhAJsgi{=LQofh^4u4l@IyS_=FT?>E_vVSo+3)SQ1Jr!0S&?WlwUFz<-y!|FcsXq`@Fk_{!ogIu+E!q$8G#VX%skZJHm1;kQV3 zHg47YG)TnzAQyQ);Ur*I2ONNDKIv|la2Ip{cJpg%9z7n@KGT6eMS($|h`S7>_{P2d z+5b*>)Dd|!j)cy%AXY@YosS~gk5UnHMViK) zRFO}H47+~Cx-pKD52iYV2}$ey5Qs8iUgN><5=t;ZB~zJp4^K=3Q=WAsk}OdKFtBIH z4gEAX0^-A3sNrrVa}5<+A%VgfJ;t&RzZy{SQnBJhD5ox+QLJ#es$^3BR+MZ}W-5vb^k?6lDNob0kThHp${ZA;WZG|@XX zYqt1YIDt$~*-`zUZ&y4P#Fm*k`;7;5z`r-+HnZVs)l$gSJT~hfRrdG#l{bn(mTsWS zR|k^>!+4Wv9y)IPS0(7RGI4+)KA&^fGo|2)4FjXvQW&z@aP zp=i^=rMe!Yl}wGar+T0yRv9T?bx+4PGE0Q4)Y0S6!6SJrUO$y%=-!8xB7xvPS0*vy z2fEb1@*QjZ8LIMFhzP~(=^i4H%_O<`EV#6@_HpT@ox6rs%2KiII^3|9jA25ItOXWW;gc244I(n+ivA_iD za~MRML5Qjg5v8TLfv?tTy|IBqIEOA|yuWN^eSqJ*y_$6Al+Gmm&kCOe{3{zKdGy~z zDQ;2w>V!wT68ysa!%yfftUS9%Dq8ivTaEJ>&M9U{dk{RKNoO+@)n+0h@2(-!}^ zMv?sS>i*fAt~$fAt}�u0y3e-II5*-=sX=M7<@@+5#eNWj8L9U_-<53n0nIw_Pv3XW_nq^d@4UbJ&7Z&Y z6(U;Czshv_fB5me?eDA~(Bp@VOrkzqA0Mo5sgIlKY%--gNi!2ln+XGW%SaizZD4U& zw?pw^BYxPb)#QwGyg(;8|Uvh+mCXgg}@ z*{or;-EJPuq)a`bk%y`JkbXpOOX-=Rw%r4VjJTsw1ye=J92zn#&`0KA5K)VpL7cVB zA7-LKpxH`1sT`WP~tZezqxYh-U5-2|B(GwO(c&gAQ2 z!FL_4_mM^$uovX}^iL?-DOv|q(j_YMReYAtR{N$r5IknqQ z3uvLtb}=o3#}6ila+U$^$40j1oMCueGOn_apLUCjmeU@%fvpc3eO6MPsBVb>YU|tE zRn%7zWb&878uc<&!ctLWuQW`xPwdx6fBV4_*qx^B_$lV%?sjo|Ow08)$b69AAuIP3 zR&;0BPxrdJb=L##%o!G3DDEN?OjST`xAdVjF5;&_7Y~2RHq3UXw}R>V<;Yy!C*|-% zNP?w0iH>9({n)l+aU&~g)+ohv-4uhpIanZVl&ohEMB8;_<3!LggIV3OjUf1Ve<{n< zboFcX4qN6?eIR8N1hRZ&5)zs>QSd6J8)g{Pg_35QQdC^ zPiypHrfW;(oWA-I$+9!AFIs!pM-S1jFx5@1mQogWeauG>(#NL0?(s4n0cFyTr!oAG(5_*c#iA4PI19U zWAqY&Kr(X%;kFDnoVB^Y3&#HveOV~J0-FQ}O$%`zkw|!%DKys^SLO6I;q==xDCa11 zvnjtWl$YdZgOBnee_)QBqR}^zJ|?XYHPO!&O5W%u?S;t8D>2D;5eUJXOoaA3M z5sY8VrBO$Ba(3r1e^?nxraSHsC-?#VgOuQZEH-*GvWG_hjJ-!Kv}-7HxJQ=?fq$hR z`siQi-;i~R9YFA?ZU>W7(zJT%-c5<9_-tamSz1f8)G(%Cr#? zKa&c7j^2=?-Vl4E9jz&z2d4-QT4oyl_jAO3a8T8taL{p0e;jP^qHm^oX}i(OW#T6& zEDMGai>+DdCM2hLxqMo4D!njkAR3aM_Qqe}n8p5!E7@1YUamq=3xB)xfcZ?VS8JnY zb~b2ic_FS-+R{s>rs9=rd|b`7r5SJr=^{iXa#Eo=LuoI`$J4e7Ltety_;@j2JMB^6 zUdz__I_S$ne=1E{Mvs~4!E4RW%h>1RrF?xg`xaKRe}su7Lfh9)UJg<$$t=?MioPz;-ioq7g3wO2+=^KdSE^~Pr!Ved%R z_~jPeBd<=|ID55IPo<&=Avnt_zR|}kdG*2y#xtcHf9{aNC0l3Ny96H0WmMg0+g_M} zO%pfQBE0c}S(re}Z6ybCveIXzyxjT=UlVf}YSNpR@ftDlP44qoRZuwTkz_*Lc^w*v zf)DrNVi_;vtClk+s7pDbRiYhxYdhDw*p& z#+x|of8%!EEVb@AncY(CcIW1z@ojLw!Uf#`-U7c!@Sv_4#k-h=?)LL;-s7Xq zd?$+E{;hj^x_Wj5`)tXHs#*1NRQ04V5t7MVf2!%@eU(rMo;v;x1I@A(EEqHf!VVGH z%Lka!!Rakt(3H+t&mhy=2FjJR!^OTv`u}3J34$oNL-|Co)InQ=d(>AWA+yD&g1Jel zqpedRg~FflBf?l%(VY)+km~WmaH~o7ZcMca;yC-j^a<-B)e4qOu>=<$6i_PRSbqGBevOON!M8(EN23Qbo`ZTsYX5F^*-y53jEHRMrUE zXkHAs#|N(v5bE#``}hPui13p2)+1gX%=iR9DUN|xkVjq(h!(hJ{4fmID^`=QiOG!7lS>aEL%W#j z4%2kR&RMre*;Ipfm4*h9fl#s|%@?SV=`q@bNr>rXYKz5oU7)p$#_Q&u3$%&pRYGPvL-Sh{1oW<^ zP)nX}+ka-_m8KWKmiZa{wvuOpYN<@4fJUo`-lQgt+BDic0a-jQ77+f3e_jU)jVryq zAmAFRPy()OiXA*SN?V)HQ)kP0+BQx*V%^Q7bVt*9id=u5dh&GVS=A#~${W5weF~7M z<+gF^iwTE3-PO&JJRR7Tr~X^>G!XXW$q1L{X*gWb)ZB7?ou{t6u40r9ztBBSW~}zU zrcrV(DkfF5j?&O#jT&odf3X^uP@Ni=(sDHh>1}FUMQhdQs=!Y?0T3F|fUBV#9dSjR z_chl}{6I4__pUs>dw=bFdpPXjaQPU$KTjWug)7GC!B|)ur-x!Kqx8{H`b3^S1!FX| z;D1c$K9i>>YoF@R)32QqO?*N9{>E47`NwES%ggk9o?eV?siAK?e?=8%Xu~+=W8*Xy zTiPEQrSUvnto>@9Ua70d)2n&<#wh*H#YmkN_MD;D3gfAkSf2hcTwc>aU-CkGe{xG@ zN99IuU3qh!{vvj>uk5oF8>8>%>F-X{_9fmGi+v{!cIX?uEA)dMi|Fsul_#H|swLiK zCr+NGMNKP!GCIyte`MK-CEh&!Q=Qg4Z?P{=KLX`OZ^xO5FNlD({~?0ZX?5jI=cu#x zKlAi@p8h9Km(NDd(E3R64x{vD?L<-f05hgd>h>1{JP!aa)I7?bizRF>5hMq%I*-g? z|I5u6X@wY&L-d*&8)2yx)S_S+1#Y1>Itf_DhXJppJ_XAtfAC$@uV5JkK1BB^SVn;@ z{0c0iz>m|@3YJmeXX$eamQmo((-##iqrflXwIr~N0$-%BD_BN>zeC?uu#5u#h<>bK z83q0cmnm3Azk7)ED(FAlN3#8J? zIT9rN`gbQVe=ETkwqxbB$rTN+>FKN%s|Ag|&7Mg-0{${>;OYsY*90~w$S z7YSIf6f;54whxh-=O)5y^qT1Mlfr{ zSR)_T+#||1L6{2IDBCO?Vq?5~|4VRHiuE)HxNVHr?ho*K8Ib1!d~;}wx5UC8b!dC6 zr_RHpeCruHir_r}e1JRL9p!bH-!Ai>OSC0)iP|N=>dN~OV~C{caGrOB+>q)KPGL_d zz+E`!e`S=fe(rAGEm%(vezO7?xz}!VOgzS z7Q|-pD35rPi=05w@VHS{M69NHZcS7n^Exe4E^#Yrcr9FF4P4?jc-p<-azEVRQ8>ht zf1n;i_5U_}@`rGqH>rxzDRCFvsh1Y80ooe*2wU*fr^%=4+9@fF$~zxc-idp6EAXR5 zFrvVZ7r|W${A3aAQQ*lU81^Xm(Mh3PraaOc^R e(*aw}>J`&Ep$5$o2mVP)i30-M3~u@DBh0 z;U1IG6&#bxc^8u#RxBNX-F~20QBYKLRVWGDjiO0|!~~_lLk*_2CO$R8lVnyae>}jl zz`Nt)cH@n4AnhTAjFV8`A|KYl9FjqBx>HC@QGms3Vc`v7gt``z1FLW6lkxIB0T<97)TU zp=<3Q9Fm=Cm=;*rZtiS#TQ7RM?XB%rv)Ao3+r7R0XH9`>>$jql=FT1o=$p3hgzB2)d%cN$Av#!b&ishe*z5`1 zRDrqLKZ`HnX0E(dfsSGkQ@AZK`IH}zU5-{T!6#tx3uaz@ z#B8TlUP_0 ze;eznNvOoX7?XT~TPXM@pset$G_C9}=E7TkEg?Rf|v(#gpqhyVN|*zf4zx)9p$n_7ilJ2qiSrG9d{6&UoJ3bZOH%q zW$zq=SfM%_CEi$16s$K)2MUpIG0*3Vd9Df?fCb+>5!(Ul(mPGMKT;Ti|b9D0-8t=6Qw6!rzA^g_DtcwusU^2lHh98wrV zuw?=VV+9H49Y}rvP)i30E7W3A_W%F@ER#VH9g{&=H-F7pd3;p$wLfRJJGmJJCj=N4 z8AFuGGKr!hCL#tBATkNa0CCvj&CE?QGBY>M5{L^`tG4!8^|iJ&*7_{9ja9m6VJ4Ug zQd_E4yJ$D7eRi{}-8Ze3^!vMaCYebl0pDMbPe|_l{mwbRvoF8<+=(ZS5YYvu)0pnt zw{O$(>whY`l;CbP7OH5d2zFQ0Rs^+ZUpS&9!&=N6)j}%P<7z}z5-K)(m4r9gs|I%` zQqe?3L$?x1sI?V+J>IC&=M4)Qs=D;T^Ofa*jW5sPcc&r|EF^jr?|A|w))S7YYCIh4 z!D_!6Pv9)9FRwelZn-z4_E+3sCuWlUS}Gn?*MEc~DpREv@2T&JE1`&5zbCHr^{Mgt zwfbv^@z$nFTs>uNQal*qp=lDuYHs4W{DZ2#(ur-zkj zCV$guIA}GLWk}4lVA2ueyCCkQGMUbxSxj@Mf|6)9Qz^*$w4iQGC?-cVrY7sRZ1RE7 zTyn`YhvqRk@^>U!z+_EoTQ;>$LTd%unY2izh2$G;UiIqF)N3fuWbia z(%CXCrgLDGZWz~2o&u{Ga1vEB+0<)N@P9F;a*uDKSsSaiIjEMrGSyHWY-Ml~*6Ib# z`i)Am7e+jn$qa_zKb}G%ax&$^gSDk}zD(!Q1x(J#`w}e!OG(Y}$T7VDM63XNIbB>z z7f}PaDdJ`lU6S(#eYsuJJ*`>oUZbUAp_X`Di%WEAPN`Y45?#h52}cA64q9dCZhtix zxg;D5Coi3#n=zMmPz$Y*sfpGyo!%E$`;>StRG2mv3xh&ws(hysag|NFD?|2Hx?CnJ zt!Juv7l;zIK{|CW95@M`nmvN?4YaY8+UW|WdE-oOO2v}lsM@kOsP-9{ex^%TE3ufC zbcfWW8jm8YxPwBaeNdIVTZ_B1$A7yoSK{vOxE6H>5g=X2W$q*ABy9AX^ zBa}A6Y_X(+6k+!!>N0$xU5Tm=3K?tAn{7wk)k?h5PCW?vy1uvup_5@XVSlGE+zG~y zC?b)@6A*KG5iyH6P%$ZYQ$$D^Wm!^cf{`%N zSTw4{LOvK22niKokrI?P%G6JL5M4?nqV3rd+a1&P#5U+!1r-;lNVt+0X;?@2`= z{N{l^SnQ0vWA!uulJBGUm(Xo=JD9)5PXC1zd`&8>Chhb=tTfx{E*Lj4kVvXguQ0Kl z{u`mKlSw7Rk$PV^fm-)rrbfS-Ot=;I6NP6?@rU_6}Fk+Ya9e2nfDybk6vx6VORJgy8N>wX*>RuY0Arn5a$ z$IuwtAovM-K&JcYeWp-{O>g$a#d_NThC`w|^uTI-p{aSiOoi4c>No8>1XQ<{cz zWl*t3 z;YBMbh(Av+$aIXp$z<|+?euLX?@0w|>IS>noFvhUA^=WR=iim-CHfv@^m@1NTCuan zPCvj4Y7^S2gnxrx7Tna(k5CvAsjfuUy~{nVMRWD5^kV`2zsS2p2Bp3c2_t{Ys|S>DQpT^Y1 zwVi$om4;&>b?=65_zaZS>Yz91_d-{H5Wd_xl{)_u|$hCWm7rRs$!n=Zn^y{ z{Y`NDcN7VoTfwZ(>pzjbDp4CmF^4-fhZ7?HLJoS%D0BZps?K6~cM61m=OzN3pQapU zwzWJV)2Jw)r9llHNjR2RuMRjcXrYCEgiTCyCW^8u6^?{ZeHmjFd+ltK*(%x_o9L=y zAz&62e}4)xjSenh86>zA`6HhOTv}5k)Jh zI=DfqTt2Ug;_kWq`ZYuVnw!SjTMkMVp&zfLD-j+R)+!3#xS zag5ItsT|t5Pt=R1hbiP`hGW;PWgPkKse2XD4&Le`AsKZ z#I)E`I8IE_9I|Ku83U82$k4EHjHDp34qA%{XS~E1%>8<2GY-SFXu_HKWl694d?~M# zd4C0CqH=mBY#QvW5>kob3JPk9L>!!5S~J#3)`?ECPVXdn9gJLTFfCSqmh$C-(E7qr zSC>KJHqqJXcMW=%=HLzJw7H!(B6}CGDe)#_oJ$}+#ya1LEskg_9K4ygl)w|WBG_^P z@8By%v_HfFkp&Yi(LQn5c0?IhGsY52B7b}>;%gVe2n(H)s!N_Uih#g4vM8@XK-<%! zMD(;aKJGB`#C(HQH;T7Anu;XD2xPa>VAa{VTV_?Hl|@;okftWwVyx>``c=0Q8!$it ziD_oZl+)!F7-k*p;?uO+Hg&Gs(AMJMD1RDQ zj&RJlCCO=ifiFp1Fz>B1f6~8af(4me51@a2~TwuQISvU=@G&6UQzV68P0yI%(w7uOjmR? zZEA0AU+Zq|iJ`R&xr3=h62r2gR)4o}c(-tPcO-k4gfTkS9qvg9*l=tTT!Y)r??)>R z(VDsvS_GrLetE$k&<9q=WMhtK$owCqHG+jZ#YN9vRF(#XeB2r{85L)#6 z0+clVFhmwfdr8rBGf{_z*dLYo9{w24G^AiEdexCVYIRmp#Ypcw$oG{19e)$f{31xr zm`5X;5|a26#XYqcRf#e5oE}q?d$joO&Ecr3iR8>EXP@N#CHx>`teFE|`ys{Tq*vpa zLe^qq4}Y3JBl81{v1h5LnAC=wG#0^aHI(;Rf&R!$LS~v1QKDTTrLypHsq$Q=JB!ku zV7$g+S5VWiG>y6&iy42c3V&>M@aOpRGFkZxGi;18tYZA!aI9b3t=9W=N!rw;(yau+ z+knK6BQZqB7nq*UPYhW+VDxGsqcSBbjl@%=)J=sbt^)pVo5qpT<5o@HU9ChS{;+5| z`5+&X`AeLJN-|7O{J*l;yS#ebz=xegjH$FXyYC+FM%?21R=@5WuYW6gvOzidGSj>w zN43ThNhnIWd1Gx9^xg$C#6^tQ*?n4^E^{?!GGjG33N=kXTpwk*^W1&q+-Ed zbiGCl3M<K9MiT<**i}DJO4vy=bv^ABKiflk+7I9JIU?4K_H)GTQ45mxi%@MP50$ZoASD+P*~jPbfxtE%J@2AlF+P)PkJyv z!X~&Ib$GM50YGmh=EwF_v`dX=S7we!nJ#I9z!^y-{(qUNgzWgwrV_lpfORwe2A$S4 z%}7&un&zkJtbi{~OPp0{svo54nqj)|Ff}syhRE45LQR3Tnlv?MXkD#OZ2Arp29d`` zXmh~wBuRnw<{H0qYxOW~%h2|t>&1F?hORnFCLDA+1!yPDr%LkBN-~*b@dcVJqj)t* zv_hiA#D5a490j29-b6G?GH}Hf9%lmq5Iaq!IyJ#OjEDVIc$USdCqp#J1tCG*a-g~< z$8!+>yPdtxtJ4(A&^2jF8b7`f>JRML(Vn5bmP2&C^+~D;1kBETev9))f0}M_)*PY_ zYZY>Be!xlRz4(F0?vB?==|s*x^I{s9DkwxfdE~(sO@no4^Z@pMr|;K^{h2G$^v7iaupFR&F+j_$ zmVc}Cr`OW-4}r7?NN?&$Zh>SO2X#rdaj=b#)7$saTmZkL1KWnEbc99&XdjMxfdeh#VA>>Q-B zoTLUHC!TR(y}ZF{U1l%0yQDO`_MbTDvVWG_EmsLq%k8?X4R)Qby^yZX4v+!kvNwRj z(C86Z>iPn91@WO1%G8`?Ayx{MG%pa(=esO|twkgBNT5B#Zs*-;UVM-}X|93stcI;= zt$4~=+E&KiG@lz-Cf!fa4PKX~d0EHM=u3Dhms~b;xg-R!S*{Xhwsji2hlFR>lz;K^ z3^xvQQ-f6;8Sr+xtQl@j^V%|QO|#E9;W#<)>aq><6&)^1z_|}=;H%>xcewDdZIJvf zcxzLG&AAWj@IIa8otB%00~s$@Sw2N`TsHm9oaP`XBMl6ZI>Kt8jC(TNd(?QmT0B0^ zS_jS?=7fHJx!|?|!T`r5HNa=QWq+I+=Dkzw&d^tEpn|2`t|6>0X9Fw_sUfN^=XJ+P zvJ8>MEH)cTTy|GUP7nGDV$SL+F&2jTJ;FpckMJ#lcAUS81PxD>1Y5ve4HIDE-K&(bI2Wm(7CiwqHGJNkrzJL7)KM-j1R(~Jlhj7*~ zKirw&M{8ZSnkRUK=!<#DvesY5Pv){EvYDO}`7T;8O8ZGNa-jaxFVTL9j!E=1(Z6Y# zL^X>pIA@fcBCC%gJ=%-H0!)Bc;_oP}Edum<4rmk!vt%k7EcTm8o@(Ft5kPaM076PO z0M43@(@`oV+t@Z4n__u>-hZ-0kLVkq`3}_!?%t$@LM7}UrHw)#vZxu85ZF(27641J z^bS=S8<+7Y1@jfnw+L4Cx^tc~P%Q9)Ocjn)Bf8!GE= zXt586$010H9JH5CqkB=PK29^}C7MaE&>5xya++?UGSh7|%XB-Hn}1%V*`_yWj_GZh zYo1Lm^L(0TUPSZFwY0!|F)cK&p)<|9XpuQYZu7NtmU$mln2*z9^Pj2GGKr55Bh_2(q;oCzKn7V1!A6;6JNUK<*+%$i zpt=)Yd@QhDB(MyB)qh)^;jhD))BKI~BK`tx)n)tw!cTYpD#XCI2dM%mF9zB&{1V=O z5NJD2Gi#4n9wfQeytHiylXhF}aq^Gw%Yhy10r8_W|F{jVzc2vLA7xv=DXSaK08xeM6n?`!-hjoEMF zq|d0-FZ_2|Xi23zhJQ}US{X4>KlydJ$cx)DHN2-1mG2A8Fa0(64=-N|l$Mq+9XOb% zpp2@65sZ#v2sH;4j1|?Cz~BMD5^CI(`DX^WVv4I;!EhEF4#s(%;cgBk4xqYnb@hVD z)o0Wj&zOD!`e>O9u$Q4wrdp0RRB!0RR9{O9KQH0Fz1(6qA{IEq|?A31FMm zk)C-veo0mmCyqiCFcCSxhisEToS;A;b`oM@I}j(Nf87%;Q9Kw)!WgJAbdge#@$VTGShh?>19?iz18S{fokj;_1PmL^763q*G0U z={^(v88d0dvL*%xV%etnfEnMN%@1Z5MfzjOtQlT3w5w?_Hq?|58K${>#aXdc;LWTm z&hO7Bljz6}#F~}~OKMjlWty2pY8QI)sVmfF>_x%X-_o-@eJ zC;zGkN;bdsE4DtdFU-65~317_2jfV!45}*{nI(n-sx|D)C=j z&Vxw{%zg1>KAYI1H-ED>9yhbuu2?FjRXeX-LL!wjGpSgzr5}tf$#i@-tkkl8+UXGP zJ~xp{eriEJ*D=*^3NZhuLqb4;7+I`!En(k-&g z>dpyI=*fwbt*)=MKiheh*tA`|8rJle%Q9#Y>}&4BnpwU7%lx#2milNhoj%Fstc47! zV+!bA=$CA1PbZV`L};2dsDa6A4i4ppJ0Xo}PF;QH1gG?^_EVUeeAGzIU`?V&RKU8k z>*_C`yhT5qNq@^ki{(tSri>YMHdD=n=(U+lOs{EBtB+R7!Br))>k=7gmd*_O=^SfA z5o|ElhZ_*6>zsO*R?EiErSoJy9Bt-g#SOZE$w*|^%kKQtMoX(`EwwXUb)hzSsITnI zLT4<^o)PLxo7qq*oeRa&sa!0P3dK^xV6${enAzsg`hR^xXqbJWTsqXBNcDxxeX)2h zIUHJ6;u~)E(0ZIte>yW5gGtY+JO1b|udtWnx%_k?ZS+w+bugXrKlBxYHZd)JW8c$P zrprg2)Xn6~CayKLCw2JgV!A{OwFNhKT0`0P$-)fj(BMC6rL9rn0)JmJ(d(hh#3P_@eFbB*;nqRT*iaV~>&eA3xcN(#8FYn@t`zU8pKOytbOWybp;ov{4jrB_0emmsE zlj*GKr*}xgx{hvvoIY(_tb~Z#=BIbkZ6f4fnNFH|JZdba@}W-$!@Q2Y&>R0R7|!{Is7gk$`h2y2P(j*!U@R?Z?ly z6@ieu=^oMLLrhK6yVEo??~|Dy2GGf+i@MIOtElSz^bz6xsN}tC^1yd~8j-+XPahWp z0tx0|(@$DC<5NgKaP+mk*>p0WGsQ>z^q@#sO#jBpeW~2RL|lW(P`ba;Z4LFu;D1Sd zr!7q_O+|tCD)J1*hC>6fJ!YuaG*h8mY!Gn>L2qv&H_*H^)t*lECo+Sf+(0ac4>NQ| z`Q)B~7;bG(e(;RYP$rFux#18($FQtrMYbk8vNhY|h^!%T?@%I(NRyE;iu}|kQ$n!} zRI_6W45pW}r-%A8=|O~~Tqd8Dkbj)j=(SlkRt{Q1+cL>WXlWL`wwzFB+A@*VU5e>N zpdHb1aA|67Jck1*>kioimnO1_TxcMd8_Gsn>~P&Ik=q9D6Og?{qNf!Zwd$n-Ih}Mr z&MJWw%FTx))6s8Pt5+NO%=7a#^wVo&+2a@qhRrAn=5* zZONx{i9r+K(6mF-!&6Ylq}+MPM~~2>K;A$nJ^7)b3<9Nz>oUK5M(OJ7NuxC8qnqh5 z;3&&u0e1vPO^);Rmmv})H5v<)AlBsD@Hu*1eEA5%OoefSKJTZ)^jRMe>K71j@~LYR zLre=Rf`ZbjmrlZnn9*`sVt>d&n@?6yQrqCmK6;A2f&gfya+3L(f|Ky$`c!_<%xEWq z)=$&dg#YW47Aj*g=$p8>sO7rS8FPKo9E4Qd^KT1q`xbo%Q7vWe%h>}{BSi@_JVW0T z_U|Jv9k-qJP;=OzA3|fqhi*UsKmAzZ!jI@##II=!2Z{f`m220BD0W1eQ~hKmC9O}y8=YqZc!bWcjaxr zte@=Im63L(nFbw$1C&P^*ul27nQPg$rDL_9iP=lztdQeiqV{AgD|ly;=ju+dI@yH^ zmfgi-&lX~^$8uMl#@GjUWiMAja*Ky&;8I{ZY~N?@+uT1*ynpKO^DM4LvgFxJp*Je3 z4F<08X7jySJ>Aa%feH0I4@)+Cc)kF>j*5VS1H3>uPX-b_L0xZfEp{X~XIloC^whe^ zMysD{!-X93@u_?oVr?$Hwx_+LqsP{4v1N=Us1Lf)_a9`^>EaqxH%g4+4Yyl|m zmv618zsp^s4S5b#btPl1;&a3`aQ&+<+_H9E=lY%7de(2*xp8yXdJmr~cS@UYTh`2M zOiOf`Ii*bDHGIC8=dzT!7jM~o(fanS9&Up;q&NwN6Mw}tud_JUPtneDsS7PdHbM+; zs%Y%zi>$s)R-eV!W%YGe)A!)R$=G0TUu?<5ty^*?Q~ZQEX7a)Qqc_ygm+%(kHtw0_ zVw8~8byFCs8M$oHgvAr7J?Y(MOQ%-2%gn|4W7;eRqp++RoakVBy4+9fd6+Sf?%N2c zg|cKxXMZ}qJ3myR7@Kb5*5M)Z9~-omJn`K6<444ne3<(Nek-Q(KjU&EBa2Xp!qd%@aZI|AZ}RcA{4cq#1$qyAkjuD#~}1xm8kta(PuzC=eQ8h*0;DPwb)A`igy6N>1W*nh~s z!Q`?@52FeSPWwP_@Wb->mPWEYgp!9JVO${3795Avg&rQY+|Pa`^7CVYrf=rYpc`h5 zn;Xnrd_cVNM3F?^PH<^7uN+8uidve5r5noL>plDw z9JU#OCg)+4J^poNCAWv4p7I1T?Za-lTYg>K@*5M?St+_6S8nmP-Bv`9tkQ(b z$Qz?N34x(PtyUZk;#!wyj7%27dveHlu}ojP)y^8~WDAq;T!sB~9>;IrpvreB?bXob!?d`Jly zoCuTs5{s;;E$P9d8h>HobhcUUokulUFXda!6vwnn<_-q<#Ir8CQ1Him7VtyXx|S*lgP*>{%8g;(RM-k!cO{DBB;8ATw6n~>^(4(V;`e(bG z>~e=sT_KO`PJem-^r)Ro=l-vL%bP4ue$G=dh*Z6RTazUY#7&kWLQj8lcx=(gT8;{9>+AwttdpTM$wz9YRh{^o0a($XK&S z-ElZ*=m=_|L+d8XhBDp8Zto{2Uq4O^(K%IU5?r z=%P)BsWZwAqqO-TSP(6o_m=Z)o66&OjG~I3pnt{aTpW#dHO#quly*SP{Ztp$E1woV zOjn9(JJIfqb{(Zz;m4i#kvHsEK0;1@vRr=O6#4S4f#sXP1+@>KG4hYY{tAEViN;|V zWzb@~Q6-HPx^awV7_^c(IF9pj2sfI-!@^@|o<)ntY3;=P3-IMtIz8*$FArq6mJuAoy6Cqx7D~TV0M;cfcKR9iZl>fPed}*))Fr7=17rF$#1SEL#gaXBJ=MvL{Q%?H5!H}Z^AaMIxQ2EZzYUeoPy+&Nci6cXPp zr#zFKWizyMvVc*^z*sExKZiDvfvp}AR zb{?gM#>dZdtf@Wa0EHTBmq(m(4SyPfIHw(B^u;^L<#^ym2dw`S@IUYX=Cig3Rysxj zgZ`cQY$ zfv4oa&%&9Kd@p*Ot2AuTkALt*m`)>iS?`OM9}>Mv^T!d?omv3;Z5f6vadSziQ^JeA z2xK`YMs=i`;%`3qSsy0c3OXC!{JP}z*XQ=B7d&NC}fQBq5?0C zxNGHS|WgU(X*XO~r7u3s>t-S{BwRZibcOa0DTVla>}WeDx^rMqn+@ zD;(i2?jGapT+R*F@SdAuE1ev12i;FnkKH@ZdB@$f;V|!uMt|j4uE$>ZEd_p?9Q)g& zUC5C^cheZ(ia)dIshh_5-6Q1N6m)luP$~=(L9%!lw>=hgAEh(HO+k0VWBgv0-Mt@k zWc6DckfjgGP+1>$9Vse~9oO&&^^W%x_^ukhN9<-y+DAj!R-!a3=)r#P)%(T74^Oa` z7>sEcbY`jHkAF6d^2d+R9NcLS&p=@zv^(<@a>)40lOx=-CM)QTf~ z9|gt{vJeswE5;4>8ax;2ge4&OUZc4@j+;7lROg%5X@AH=AJh4%nmosI=s_KEE+Fsm z!x;0>-|Wmat6V@arCA&@e3M2)TGlDxVLrL7<|7Dq#5knfj}y-uo~ZLpyvvt(XH6acJ%JV>{}r7V+gsQ0!Vxax&MdA zQ$IzD_&Kb5jH(%Mp6h82FGs1h0tdU50z5$T_-Q(ce@F9GHw976El_vT$?7?*H(Opv zY;cgn8UpRNHipOeQx8SE8={BkOoyd)U~@NtdrY|baB1iW z_Jy24Zhp`P56B}M476s;+mWS#T7%vBVUhOQyCfXB9BTqj{mnC*UukvLHeiK_tA>w6 z+=8|vhNIPU4^`Fh=Of+`LZwhU#!p7P^5Ju7JQ%5R1id}Zps%NfzZ|Rr7L0fUKnfDR zqkjPp#?S@*0B>$u>Eejf*=hug+WYB=#)ha4s6ZxmKy56Kr(d0jrvRY#&UN(pnixkX zr*9U`QY)yHzoVmVb)!tcmLkp(0lAR=z3mTSP1#(~SsO4!eUIJ1}gkJNv<;F#!P9XX8GQ259l@s&9r>U6M-+GjDL09 z7}ogGRq+&`EjvZQ!$`LLpZlpXjI!d>WBik-K!O(l362sR=3vnEF#jruu9ph@vK(X3 zd5HfQaXVTKcdMsS&-Db1{j>n!Wt|076m8qbm+tOZkW}gJM!LI0y1Q!yL_(yN?q*>n z1*D`yKsu#Eq@*N8gm1<7eSCSoJ!j9HGv_zgea$^H_s-lq*Z)sOy2V~8e^=cYK#>J6 z3n=?xsGrs@&`cakMA4)9Qx?K`r`7%yc#dh zkSbs}6@EVUDX1ym#zKqk+cMR0lQ>Ldi|4M+Nil)sZ6Q(%tJZa2vO^rJ*2rtpg^d+O zV)i<;m!bYa7F3_XAiQIw3iJFP2eF`Kfw#mGtI=Vnu+l@F*K#wvNILQG8gM@WEaNE_ zeg1gLa&xHuwK9OK{7d<#9+Cs(ME#|e7zQ+kyVGL4#&GlUkB%YG!pdBnlZ_Yd+qHf0 z6o?)%W?s=oun}v{QTsIA@S?`hKppBh?T^%;OIL3Dwy?AuB->yKT?*E(jNhQ0lhcU( z7Axd19U(eOJ~Oci|4b+61w^#S!Bg_QvmWWZ^j9*0wAX7g#5v;Wt(wl!oEpjT7ev@l z=1zf%DyJ~fdf!>Nn+hl`)h|@M6QM9!HQb5H{0)H>TQ~veiEpU<-awkaisc?m`Kvc| z;yVwTvyfr|C7r2lB}WcytvJ9v0NyguW$!7A>_^&)Q*7bfMfyZ>WGq z{)M-KoM_xT(^SoM=FHivwe+au(lLN!8M-^LE2)ArNu!dN&u)KB7_2i@J;j|Vrq(fi zCKF&VB|f1>x1La{=lZ{i^DMCk^9f&GMeQkTDckupj)Qz*IoP@Nhk9W%L7eK-MIw5y3i z7Jt7nEm{a$n5f6^HmciG>&EZH79i7Lwf7NQsoyhIGZVxa#!nR9n8w7v4ugfD6c7Ot_35 z!bGk#_g}4;Ju)hjr@vI88w~mGq0BsJjxndgU6o7~pDi&1DX19O&5fZHLO@ppf6>n9 zQa}CN|7s;>5tpp5?lUj;3;s8h0?8oAf)Bh-8UNG$32VNjv?6>vhaRnW$&wS5Ul-ra z5OMhgd?u`u%`fL#Tt}@gxYpB)@*eJGn)*uX|DK6r4dpR%HkhzHj}#S6;5{+31OIS< z>rQ>1c;z8JlNj~SL#ZQTH|Ovr=5-c5>5m+P&{rrN0~iUL2>4YSLf<{*bdrz`~+3QwxAOK(@c>fo7dJHdUy-^*#WC;>#_{SF?fs1`NXx{h@p4b}(v<)5`Gpu`vR#5>Kdfj(yv`ONQfmhpE!S-nq^ zx8Q)oDyPTaIg;j|Y}>d>?bI*z`f3z6Efy0atS9|Lg)|ZRJeExStB565@5LKiN@`A- zNmfR8B!Xblf#Od2*8nUnctaDkxU^nOS%UO_UN(bVCpq)oAXW-qvSfR4#<60H#%YIB z84%ni*Oxq6+X84CKa%c3T~?Me0h<|igS=pw=E{1%E#HEW+Kq|h^8_vA)1?_Ku?TpC zttgypO9N1Wd8l?Y&N_I$Qr)fa(TM2mqwxV(L*ASt1^zKG>6&ZqUH?QEg8UcL;I8p$ zTTIZ4fJxFWJ8qjQGVoAv_c0zmwY&E%>GL_A{7#FW?~jo)GWy;yzMHk0#47FRS_;Sr z9J3gBdXDjwROH~f;^5G_&(u0qiywdvD-v#tL)0_&6H+|2a*n@OuAkMS9;bI~#GP&z zNtDU7$M(H8u(=6@)d1#0WC#wW1Hv3P{!~**ya9p|R1^^;aU;wqcscv+ZVZ~$ZMM|< zC5t#9sD)7&k{X1@K8z^}1G&2e!qHJXl5G(fc*cGJ9ERCv#`2QhGyB!1lEJ~CAo%(I zu+HJrQ|qf8UTZHo`{Bj&!bZJITCkPka4uJQUOI8DivrlIHxIab1CgG{*?&B`=&W=A zIRshP=6(F6*NieTt1A8K)55W9`|?~S(p=)LOan*H2eDlW6DJm`>guUs_F_nTqW|z_5<^N7e zqdLkxbnj@XfiBPipAVXgC1@SC6coSB<>=vZqi-1l2yrMoiVcow{qCp$P3B z+EYOSr!y5rIUq48E1P3-6D5P72B8yl?MMzwgg;#M-k!mqIVWXEF1 zWD`UoV(IoS$$+H_ZA5E)#I2OJ?Iv|U+He@WE+s;uzppFXt^DSIGFXkD>vSpCBC39$ z2!grX(hAnK4f+;>h<64--K6i481`M@!i-UFXhshplPa_cG>5_omWw{BIS*|q51b3Hxug|{q#+6y7z1(4=dNel!v;rB#OkMhDzd`b=4;n2G0%<7 zceq$gEd=Jc98PhZRwa~Qkv$hEAWLPY^;1Ho>VNY5y+_WLONycft{x{Nc+Zr?$bTKk zmiA2hP#_7Pr*GU-qU4cu<)rAk+n5LGDAjS!VhTa0j4}EqP?_TlIK$l3osVlo(hEXh z54#*E=x_a~*}#Yf=?jE76ViNAP*!M#CWqQ;phOi!I{Ow|1p@iCpIp75`F7~Z1njOv z__oNYcT6+!`L|3zbBi8BZg0GWBI9zu9Fq;l@NvvO$?`HG@`mTY*JI8@`F+ash5YiQ z&feH&RN|T^M83PmiToVo;)aK{K#;zasp&A6AU+yt-GIQY8YECZ@!3`Eda~;zmAc4NH&(H+`%uw*i`$MRT1j9X~G~(8)$`; zM43@y;1K<%Y0%l71hMvw5GI-i7}Q(S%bDmgK-fXCqFb;|LHm>Ez>K`6O^!LYrG>?L zN64c-gyhChLm>3!#TJ@H-3{IWZee6si8T3gF<9c2w@5#Iu(9P6mTC8Qt}SuR10&*EXUh~- zmhBv$bp{@b^WMp0dcOgmX=zSt==(3+u%1wwi4BX&JW<$Trx-`aijuNusIGg{AoNM- z?TNDh`_sV?+5=zxKHS1C;TSxT!f81U@@(q$fwg70_+tJEpvxrso;HVcskwSV#(-JV z#50m~sn^-Jx>-BP*Bst9`shv5J_#?p5Z9+XlaxQEOL?uKT$dKm*rN#D-J4V%=3Ve} zz$9~RQ10&}ph?0Y?E_T`IYLnaO68?3ODvwJtj-3C&ItWjFQ*xvDqP7+Ar#bM=-_Jd z%x|8*f{DPJ5H#qVJ_>$yYgb~Ja$STo4%0w@__hg~e=2UQzZ`AeUTXH-KD<4>sP2GU z>Z0mlcU?)25jS|;=E2lf(O{$F0P~5l=QE5OmM+`fq8O#&5?TR2m9inTH%%oT|{3}tSUH|~I(H8k$y<4*hEHWvFeZlbLV=c?>v93w2e zKHiud(&RKFvYj21HpeXYcz2-JYY9{3%qVpwamv#((eOJs)m6HRIHGI)eC80he9_AL zIMJLmuy4!{)CH5_oEP_JV0w-pjww5XCbcSPuSC`J(Isswy2ed?p;@kC?PV*r! zv7B-!=BNMa_nJ|T&loBDLH_41&CBNUw>$NP%9F2~=;}B3OF`E;Q@A5OA3J?WU2@&$ z%D<~Sozn{)o5WBh&XXXSJsY7>>&iuH26C}7U)FZ*?Yqu7>q)ZcSq`d{6 zEUH{SNJH5=m>e$q8Mr8&&Xv#|$nOTE=EQ`u4NZpA1UYp^h2@cxMvDx0+I9qR7$`y-DyVNpm&mGu2Q#VetlyJjN+J7*712H4`oLzOf85$>& z@nL8L2r^Xxnn-}+P*v~@HJw$he2c}bFU_aTTb^*2p09-3oAtSzI|qvqBEKq16?>$4 zjICLJo=8q*u-Hc%W2_zAp5QHVM8Rg@D$Xa<*5f-u0R%I;FkMkA(gxDEg6E6pC>VhZ z3VF$d>22EI)3fWUcrAn}7Zc~>`cq{q9PRU4Kt8mlYCZ>u{ybx6OC>WxTQ*i{+h%P7 zVq&)h)Wzc8%!Pg_pm0xSrL1IUknKML?>yok?+P2dJ$Q5{{AH{cAKkr*r)q+lG}~-v ztucn(k|8e3YRhzP`>ylM6(*Rf>;2uHp#z#S_T)!ez>nA@l9QPf0idH~-<+EoIAncJ zz&OZ^U0nQT&d}dVSEKJ-(%mY+qz4UA!0Rd6AP_|#(On!v zU__ljtp&K1s1VmEmBqs%Nj*~lPTA?Pz97?Mp0_25Z6}J*!>VtHicRsUS;XjY-N9v* zQ;c;qxM|GBqE{*63>~;D-{e!RhJ&uGZC5}2Y~TE8v+{{wJjf5bGazWd?Pk8V>rhy# z$Iv5rTeCo=z)FY^jbX&e6vWRX$L~4uHe-Xw)jiyiudEMmB#u$e&tsa+>R`OI-ez6| zf5^7bfR%bC0&8^6MZe4&Ts33TUxx8YlHekW*o02ogG_Xwxn!G*N4TFgD`+Cfcdn)C z&@LuE;_xh#^J>$kOYu8}S2*`eWoCk5eZ!%(T6d-4?Pj7y_5q-rAE<6EJr$n4r?6)s zotV4cn6c6EibG8ZJ@cdX3YWsvMDvK7K^|1g&5N!v&?bai+EYeJ$T>B$g^Ym9o`+$? z*l>))>uiKtEgq^>V~mYt_d0hf$Bg@}OLc>ucwAgCX6{8?FjHpjM;s*9mp2(Jge(_D zM668O#sO&L?iuLRY#=<4lFn7F#0{&hI&@RLCMoQToY!Kl$(%G(NT}I$ExP1JCr5)e zg9s4~%NaO@nUt-eDRn`WV|dAt472h87)*4%83#GGA=2Amg40)df$8hxNIWK(v?^mQ zk%319M8z1$!Ccl6Y^B^4j5HD)d18AQFdK;7Hs*|;e!5eP|x$wM>dT{3A^mC?(+ zC?(FVCW1xbc_q$1w+3nx{hpffue`)$h6|1jGRw2iKR~*X6*+WD+TM&N;LLHbj7}Tk zVK7w@@ibTv^uSAkt!|#2jEnL2`iECphhS6@e43gyavJj<9M>JFmtW(bXOjBN#~iwx z$-U8Y(To;_1e1OIi0G2NuTpiX2;~Q_IzUhGoDW{TIgy%;6njHY1!=;CFQ@U( zcTdPNBf&<9kHfzV<<8Aek?olZJ$=9bLx7ir4f;MW08K`qc-wjcpR1H6vk_W~qj*!P zMh8YBxg=kJ zfH46PL&R1xw-);go+EyXfqlpyH!r*6`f9R+4yG_gO9X>v4K&xI*d}7aIUdo5rlpcZMR@4yDb|##?q7M|y zehzkrC*^~>GVO_Ql%zq){$r-W&lUocFI*xTfpSSA?2CJw$r{$rYZ5%8YAenklhKOW zbjc%mmJ$%Ma_jF+bq$r^D;wGHHC-zPdZ*#2%`NHFNul3LJrRB0(I+I@P-~M}%e1SnjOmh0Ku$c#1m1H(R*zDYxX7nUD`e)b{Li z%ECYaRW2W*vpqhjVZ=0|`@F7SHF0d6EJV7T^%jf3c?Ks=U&jpn;Dun*i*$(>&R%W6 zr_-NN_GU^$JknlVtiPi?)ABsN5GN1a@0Y4z#Fz}K+4Rer5uRO6dEr~7J!coxk_`Xu z{L*9I_LEKKAf@x80u&ligbnPRwh-11IqewG(6ro9SE!Uvy3rw~Atdt2nezEKwW!%n zwTy`AQX&-}4^uukKH}^bdnfTkcA8Dc5vK1ey7gXqc-x6g()4FWXPBwkU3J|08ogU% zo8fM>?#FAAldqN6^oG`uQUe6o%o=feaeS5T9npDp{t~TkShUZQL zI>fk1?L{c*TSaoAduot08H$$_ORG%t`Al55SK|a^yT+Du$XY3a>lallo>Dxp(m!-?gu_djZfSJgV~2K<{5r)Zs)Tlia3-} zFHJshdN!j~_ru^%L1k-kY5Nllu9A9J% z{Obzx?{n(>834>KNrUk}zDeMMJZj)yQwab->_JFg#ot@V-yg5P{ssU5`vdBwGB~u9 z2<$J-a1Z`fX8(7FeyaE2y)A+mf*k{q7pdzr|Gm0Mmnr zarQ7+hVp-_e;zg&8FZhYdKow6-}!3*;L(F9+9CH)O(5}o*Znj52)n+hAAk*6_h5Al z(LMOjJQ-{!9u5gsx%Z&N8_Ij|?*lIY!1f>_P5y82|Ie=d$>i@n{J*=hvG~3LhH`F7 j?}-17Z2-WN2f-~N_fT{>BQ`C777&RA0Mu6h9^3v87 /dev/null && printf '%s\n' "$PWD" ) || exit +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -114,6 +112,7 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -171,6 +170,7 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -203,14 +203,15 @@ fi DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ "$@" # Stop when "xargs" is not available. diff --git a/gradlew.bat b/gradlew.bat index 8508ef684..7101f8e46 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,18 +13,16 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem gradlew startup script for Windows +@rem Gradle startup script for Windows @rem @rem ########################################################################## -@rem Set local scope for the variables, and ensure extensions are enabled -setlocal EnableExtensions +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @@ -51,7 +49,7 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -"%COMSPEC%" /c exit 1 +goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -65,18 +63,30 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -"%COMSPEC%" /c exit 1 +goto fail :execute @rem Setup the command line +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% -@rem Execute gradlew -@rem endlocal doesn't take effect until after the line is parsed and variables are expanded -@rem which allows us to clear the local environment before executing the java command -endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel +:mainEnd +if "%OS%"=="Windows_NT" endlocal -:exitWithErrorLevel -@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts -"%COMSPEC%" /c exit %ERRORLEVEL% +:omega diff --git a/llms.txt b/llms.txt new file mode 100644 index 000000000..fe7066119 --- /dev/null +++ b/llms.txt @@ -0,0 +1,65 @@ +# Store 6 + +> Store 6 is a Kotlin Multiplatform library for reading and writing data that lives in more than one +> place: a network, a local database, and memory. You describe a key and a fetcher; Store handles +> single-flighting concurrent demand, staleness, invalidation, and bounded memory. Every zero-config +> behavior is named and covered by a conformance test. Published under `store6-*` coordinates in the +> group `org.mobilenativefoundation.store`, side by side with Store 5 for the whole 6.x major. +> Status: in development, targeting 6.0.0-alpha01. Nothing is published yet. + +## Start + +- [Quickstart](docs/store6/quickstart.md): the five-line store, the full runnable program that CI compiles and runs on every pull request, and the experimental write path. +- [Important Defaults](docs/store6/important-defaults.md): every zero-config behavior — freshness, retry, cache and memory, single-flighting, emission — each line traceable to a named conformance test. + +## Use Store + +- [Keys and Namespaces](docs/store6/key-design.md): key design is the one skill Store asks you to learn. `StoreKey`, `StoreNamespace`, `canonicalId()`, and namespace-level operations. +- [Invalidate or Clear](docs/store6/invalidate-vs-clear.md): invalidate marks stale and refetches for live streams; clear removes and never replays. Which one you want, and why. + +## Concepts + +- [The read contract](/docs/store6/concepts/read-contract): what `stream`, `get`, and the four `StoreResult` kinds guarantee. +- [Freshness](/docs/store6/concepts/freshness): the freshness policies, conditional-fetch boundary, and stale-while-revalidate behavior. +- [Errors](/docs/store6/concepts/errors): the six `StoreError` categories, error-as-value boundary, cancellation, and retry ownership. +- [Memory and lifecycle](/docs/store6/concepts/memory-and-lifecycle): residency, idle-key bounds, Store lifetime, and persistence choices. +- [API tiers](/docs/store6/concepts/api-tiers): stable-track, experimental, and delicate surfaces and their opt-in rules. + +## Guides + +- [Fetchers](/docs/store6/guides/fetchers): lambda, result-aware, and seam fetchers, including conditional requests and retry composition. +- [Persistence](/docs/store6/guides/persistence): the SourceOfTruth and Bookkeeper contracts and how to choose or certify an adapter. +- [Testing](/docs/store6/guides/testing): fakes, contract kits, policy tests, and deterministic time. +- [Devtools and observability](/docs/store6/guides/devtools): logging, telemetry, inspection hosts, and the runnable demo. +- [Extending Store](/docs/store6/guides/extending): decorators, runtime capabilities, key events, overlay projection, and wall time. +- [Performance](/docs/store6/guides/performance): measurement commands, benchmark scope, and evidence boundaries. +- [Swift](/docs/store6/guides/swift): Objective-C export, SKIE, coroutine bridges, duration representation, and target coverage. +- [Room adapter](/docs/store6/room): connect Store to an existing `androidx.room3` database and its sidecar metadata. +- [SQLDelight adapter](/docs/store6/sqldelight): connect an existing SQLDelight schema with atomic value and metadata transactions. +- [Compose integration](/docs/store6/compose): collect Store streams as Compose state without unstable recomposition. + +## Mutations + +- [Mutations overview](/docs/store6/mutations): the journalled write path, experimental posture, and adoption sequence. +- [Mutations quickstart](/docs/store6/mutations/quickstart): register a typed intent, project it optimistically, push it, and observe the acknowledgement. +- [Authoring mutators](/docs/store6/mutations/mutators): the five registration shapes, codecs, purity, and invalidation effects. +- [Pending-write UI](/docs/store6/mutations/pending-write-ui): render optimistic overlay state without confusing it with staleness. +- [MutationServer](/docs/store6/mutations/server): implement push, idempotency, acknowledgement, conflict, and retirement transport. +- [Conflict resolution](/docs/store6/mutations/conflicts): select preconditions, merge into a new generation, or accept server-wins. +- [Aliases and canonical rekeying](/docs/store6/mutations/aliases): move a provisional identity to its server-assigned canonical key. +- [Draining and restart](/docs/store6/mutations/drain-and-restart): keyed and global drain behavior, backoff, hydration, and replay. +- [Journal storage](/docs/store6/mutations/journal-storage): choose the in-memory default, SQLDelight, or a custom storage seam. +- [Inspection and observability](/docs/store6/mutations/inspection): durable pending and dead-letter truth versus advisory event flows. +- [Testing mutations](/docs/store6/mutations/testing): certify journal storage, crash boundaries, and projector purity. + +## Migration + +- [Migrate from Store 5](/docs/store6/migration/from-store5): map Store 5 concepts and APIs to Store 6, including side-by-side adoption and rollback boundaries. +- [Component map](/docs/store6/migration/component-map): translate Store 5 components, policies, and extension points to their Store 6 equivalents. +- [Migrate from Store 4](/docs/store6/migration/from-store4): move through Store 5 or adopt Store 6 directly, with explicit compatibility and validation checkpoints. + +## Project + +- [Stability](STABILITY.md): API tiers, the deprecation cycle, the release cadence commitment, how stability is verified from a released tag, and the mutations posture at alpha01. +- [Roadmap](ROADMAP.md): operating principles, the phase table, target windows, and how to contribute. +- [Contributing](CONTRIBUTING.md): how to get involved. diff --git a/multicast/api/jvm/multicast.api b/multicast/api/jvm/multicast.api deleted file mode 100644 index 986699944..000000000 --- a/multicast/api/jvm/multicast.api +++ /dev/null @@ -1,8 +0,0 @@ -public final class org/mobilenativefoundation/store/multicast5/Multicaster { - public fun (Lkotlinx/coroutines/CoroutineScope;ILkotlinx/coroutines/flow/Flow;ZZLkotlin/jvm/functions/Function2;)V - public synthetic fun (Lkotlinx/coroutines/CoroutineScope;ILkotlinx/coroutines/flow/Flow;ZZLkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public final fun newDownstream (Z)Lkotlinx/coroutines/flow/Flow; - public static synthetic fun newDownstream$default (Lorg/mobilenativefoundation/store/multicast5/Multicaster;ZILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; -} - diff --git a/multicast/build.gradle.kts b/multicast/build.gradle.kts deleted file mode 100644 index 949c8b33b..000000000 --- a/multicast/build.gradle.kts +++ /dev/null @@ -1,24 +0,0 @@ -plugins { - id("org.mobilenativefoundation.store.multiplatform") -} - -kotlin { - - sourceSets { - - commonMain { - dependencies { - api(libs.kotlinx.atomic.fu) - implementation(libs.kotlinx.coroutines.core) - } - } - - commonTest { - dependencies { - implementation(libs.junit) - implementation(libs.kotlinx.coroutines.test) - implementation(libs.turbine) - } - } - } -} diff --git a/multicast/gradle.properties b/multicast/gradle.properties deleted file mode 100644 index fc5e9450c..000000000 --- a/multicast/gradle.properties +++ /dev/null @@ -1,3 +0,0 @@ -POM_NAME=org.mobilenativefoundation.store -POM_ARTIFACT_ID=multicast5 -POM_PACKAGING=jar \ No newline at end of file diff --git a/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/Actor.kt b/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/Actor.kt deleted file mode 100644 index 9a483141a..000000000 --- a/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/Actor.kt +++ /dev/null @@ -1,34 +0,0 @@ -package org.mobilenativefoundation.store.multicast5 - -import kotlinx.coroutines.CompletionHandler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.ReceiveChannel -import kotlinx.coroutines.channels.SendChannel -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.EmptyCoroutineContext - -/* - * Credits to nickallendev - * https://discuss.kotlinlang.org/t/actor-kotlin-common/19569 - */ -internal fun CoroutineScope.actor( - context: CoroutineContext = EmptyCoroutineContext, - capacity: Int = 0, - onCompletion: CompletionHandler? = null, - block: suspend CoroutineScope.(ReceiveChannel) -> Unit, -): SendChannel { - val channel = Channel(capacity) - val job = - launch(context) { - try { - block(channel) - } finally { - if (isActive) channel.cancel() - } - } - if (onCompletion != null) job.invokeOnCompletion(handler = onCompletion) - return channel -} diff --git a/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/ChannelManager.kt b/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/ChannelManager.kt deleted file mode 100644 index aa913f577..000000000 --- a/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/ChannelManager.kt +++ /dev/null @@ -1,433 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -@file:OptIn(ExperimentalStdlibApi::class) - -package org.mobilenativefoundation.store.multicast5 - -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.channels.SendChannel -import kotlinx.coroutines.flow.Flow -import org.mobilenativefoundation.store.multicast5.ChannelManager.Message -import kotlin.coroutines.cancellation.CancellationException - -internal interface ChannelManager { - suspend fun addDownstream( - channel: SendChannel>, - piggybackOnly: Boolean = false, - ) - - suspend fun removeDownstream(channel: SendChannel>) - - suspend fun close() - - /** - * Holder for each downstream collector - */ - data class ChannelEntry( - /** - * The channel used by the collector - */ - private val channel: SendChannel>, - /** - * Tracking whether this channel is a piggyback only channel that can be closed without ever - * receiving a value or error. - */ - val piggybackOnly: Boolean = false, - ) { - private var _awaitsDispatch: Boolean = !piggybackOnly - - val awaitsDispatch - get() = _awaitsDispatch - - suspend fun dispatchValue(value: Message.Dispatch.Value) { - _awaitsDispatch = false - try { - channel.send(value) - } catch (e: CancellationException) { - // ignore - } - } - - fun dispatchError(error: Throwable) { - _awaitsDispatch = false - channel.close(error) - } - - fun close() { - channel.close() - } - - fun hasChannel(channel: SendChannel>) = this.channel === channel - - fun hasChannel(entry: ChannelEntry) = this.channel === entry.channel - } - - /** - * Messages accepted by the [ChannelManager]. - */ - sealed class Message { - /** - * Add a new channel, that means a new downstream subscriber - */ - class AddChannel( - val channel: SendChannel>, - val piggybackOnly: Boolean = false, - ) : Message() - - /** - * Remove a downstream subscriber, that means it completed - */ - class RemoveChannel(val channel: SendChannel>) : Message() - - sealed class Dispatch : Message() { - /** - * Upstream dispatched a new value, send it to all downstream items - */ - class Value( - /** - * The value dispatched by the upstream - */ - val value: T, - /** - * Ack that is completed by all receiver. Upstream producer will await this before asking - * for a new value from upstream - */ - val delivered: CompletableDeferred, - ) : Dispatch() - - /** - * Upstream dispatched an error, send it to all downstream items - */ - class Error( - /** - * The error sent by the upstream - */ - val error: Throwable, - ) : Dispatch() - - class UpstreamFinished( - /** - * SharedFlowProducer finished emitting - */ - val producer: SharedFlowProducer, - ) : Dispatch() - } - } -} - -/** - * Tracks active downstream channels and dispatches incoming upstream values to each of them in - * parallel. The upstream is suspended after producing a value until at least one of the downstreams - * acknowledges receiving it via [Message.Dispatch.Value.delivered]. - * - * The [ChannelManager] will start the upstream from the given [upstream] [Flow] if there - * is no active upstream and there's at least one downstream that has not received a value. - * - */ -internal class StoreChannelManager( - /** - * The scope in which ChannelManager actor runs - */ - private val scope: CoroutineScope, - /** - * The buffer size that is used while the upstream is active - */ - private val bufferSize: Int, - /** - * If true, downstream is never closed by the ChannelManager unless upstream throws an error. - * Instead, it is kept open and if a new downstream shows up that causes us to restart the flow, - * it will receive values as well. - */ - private val piggybackingDownstream: Boolean = false, - /** - * If true, an active upstream will stay alive even if all downstreams are closed. A downstream - * coming in later will receive a value from the live upstream. - * - * The upstream will be kept alive until [scope] cancels or [close] is called. - */ - private val keepUpstreamAlive: Boolean = false, - /** - * Called when a value is dispatched - */ - private val onEach: suspend (T) -> Unit, - private val upstream: Flow, -) : ChannelManager { - init { - require(!keepUpstreamAlive || bufferSize > 0) { - "Must set bufferSize > 0 if keepUpstreamAlive is enabled" - } - } - - override suspend fun addDownstream( - channel: SendChannel>, - piggybackOnly: Boolean, - ) = actor.send(Message.AddChannel(channel, piggybackOnly)) - - override suspend fun removeDownstream(channel: SendChannel>) = actor.send(Message.RemoveChannel(channel)) - - override suspend fun close() = actor.close() - - private val actor = Actor() - - /** - * Actor that does all the work. Any state and functionality should go here. - */ - private inner class Actor : StoreRealActor>(scope) { - private val buffer = Buffer(bufferSize) - - /** - * The current producer - */ - private var producer: SharedFlowProducer? = null - - /** - * Tracks whether we've ever dispatched value or error from the current producer. - * Reset when producer finishes. - */ - private var dispatchedValue: Boolean = false - - /** - * The ack for the very last message we've delivered. - * When a new downstream comes and buffer is 0, we ack this message so that new downstream - * can immediately start receiving values instead of waiting for values that it'll never - * receive. - */ - private var lastDeliveryAck: CompletableDeferred? = null - - /** - * List of downstream collectors. - */ - private val channels = mutableListOf>() - - override suspend fun handle(msg: ChannelManager.Message) { - when (msg) { - is Message.AddChannel -> doAdd(msg) - is Message.RemoveChannel -> doRemove(msg.channel) - is Message.Dispatch.Value -> doDispatchValue(msg) - is Message.Dispatch.Error -> doDispatchError(msg) - is Message.Dispatch.UpstreamFinished -> doHandleUpstreamClose(msg.producer) - } - } - - /** - * Called when the channel manager is active (e.g. it has downstream collectors and needs a - * producer) - */ - private fun newProducer() = SharedFlowProducer(scope, upstream, ::send) - - /** - * We are closing. Do a cleanup on existing channels where we'll close them and also decide - * on the list of leftovers. - */ - private fun doHandleUpstreamClose(producer: SharedFlowProducer?) { - if (this.producer !== producer) { - return - } - val piggyBacked = mutableListOf>() - val leftovers = mutableListOf>() - channels.forEach { - when { - !it.awaitsDispatch -> { - if (!piggybackingDownstream) { - it.close() - } else { - piggyBacked.add(it) - } - } - - dispatchedValue -> - // we dispatched a value but this channel didn't receive so put it into - // leftovers - leftovers.add(it) - - else -> { // upstream didn't dispatch - if (!piggybackingDownstream) { - it.close() - } else { - piggyBacked.add(it) - } - } - } - } - channels.clear() // empty references - channels.addAll(leftovers) - channels.addAll(piggyBacked) - this.producer = null - // we only reactivate if leftovers is not empty - if (leftovers.isNotEmpty()) { - activateIfNecessary() - } - } - - override fun onClosed() { - channels.forEach { - it.close() - } - channels.clear() - producer?.cancel() - } - - /** - * Dispatch value to all downstream collectors. - */ - private suspend fun doDispatchValue(msg: Message.Dispatch.Value) { - onEach(msg.value) - buffer.add(msg) - dispatchedValue = true - if (buffer.isEmpty()) { - // if a new downstream arrives, we need to ack this so that it won't wait for - // values that it'll never receive - lastDeliveryAck = msg.delivered - } - channels.forEach { - it.dispatchValue(msg) - } - } - - /** - * Dispatch an upstream error to downstream collectors. - */ - private fun doDispatchError(msg: Message.Dispatch.Error) { - // dispatching error is as good as dispatching value - dispatchedValue = true - channels.forEach { - it.dispatchError(msg.error) - } - } - - /** - * Remove a downstream collector. - */ - private suspend fun doRemove(channel: SendChannel>) { - val index = - channels.indexOfFirst { - it.hasChannel(channel) - } - if (index >= 0) { - channels.removeAt(index) - if (!keepUpstreamAlive && channels.isEmpty()) { - producer?.cancelAndJoin() - // Clear the dead producer reference right away instead of waiting for its - // UpstreamFinished message. Otherwise a downstream added before that message - // arrives would not restart the upstream and would never receive a value. - // The stale UpstreamFinished is ignored by the identity check in - // doHandleUpstreamClose. - producer = null - } - } - } - - /** - * Add a new downstream collector - */ - private suspend fun doAdd(msg: Message.AddChannel) { - check(!msg.piggybackOnly || piggybackingDownstream) { - "cannot add a piggyback only downstream when piggybackDownstream is disabled" - } - addEntry( - entry = - ChannelManager.ChannelEntry( - channel = msg.channel, - piggybackOnly = msg.piggybackOnly, - ), - ) - if (!msg.piggybackOnly) { - activateIfNecessary() - } - } - - private fun activateIfNecessary() { - if (producer == null) { - producer = newProducer() - dispatchedValue = false - producer!!.start() - } - } - - /** - * Internally add the new downstream collector to our list, send it anything buffered. - */ - private suspend fun addEntry(entry: ChannelManager.ChannelEntry) { - val new = - channels.none { - it.hasChannel(entry) - } - check(new) { - "$entry is already in the list." - } - channels.add(entry) - if (buffer.items.isNotEmpty()) { - // if there is anything in the buffer, send it - buffer.items.forEach { - entry.dispatchValue(it) - } - } else { - lastDeliveryAck?.complete(Unit) - } - } - } -} - -/** - * Buffer implementation for any late arrivals. - */ -private interface Buffer { - fun add(item: Message.Dispatch.Value) - - fun isEmpty() = items.isEmpty() - - val items: Collection> -} - -/** - * Default implementation of buffer which does not buffer anything. - */ -private class NoBuffer : Buffer { - override val items: Collection> - get() = emptyList() - - // ignore - override fun add(item: Message.Dispatch.Value) = Unit -} - -/** - * Create a new buffer insteance based on the provided limit. - */ -@Suppress("FunctionName") -private fun Buffer(limit: Int): Buffer = - if (limit > 0) { - BufferImpl(limit) - } else { - NoBuffer() - } - -/** - * A real buffer implementation that has a FIFO queue. - */ -private class BufferImpl(private val limit: Int) : - Buffer { - override val items = ArrayDeque>(limit.coerceAtMost(10)) - - override fun add(item: Message.Dispatch.Value) { - while (items.size >= limit) { - items.removeFirst() - } - items.addLast(item) - } -} - -internal fun Message.Dispatch.Value.markDelivered() = delivered.complete(Unit) diff --git a/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/Multicaster.kt b/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/Multicaster.kt deleted file mode 100644 index f006e853b..000000000 --- a/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/Multicaster.kt +++ /dev/null @@ -1,136 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.mobilenativefoundation.store.multicast5 - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.ClosedSendChannelException -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.consumeAsFlow -import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.onCompletion -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.transform -import kotlinx.coroutines.withContext - -/** - * Like a publish, shares 1 upstream value with multiple downstream receiver. - * - * This operation still keeps the upstream flow cold such that it is suspended until at least 1 - * downstream value collects the latest dispatched value OR a new downstream is added while [buffer] - * is empty. - */ -class Multicaster( - /** - * The [CoroutineScope] to use for upstream subscription - */ - private val scope: CoroutineScope, - /** - * The buffer size that is used only if the upstream has not complete yet. - * Defaults to 0. - */ - bufferSize: Int = 0, - /** - * Source function to create a new flow when necessary. - */ - private val source: Flow, - /** - * If true, downstream is never closed by the multicaster unless upstream throws an error. - * Instead, it is kept open and if a new downstream shows up that causes us to restart the flow, - * it will receive values as well. - */ - private val piggybackingDownstream: Boolean = false, - /** - * If true, an active upstream will stay alive even if all downstreams are closed. A downstream - * coming in later will receive a value from the live upstream. - * - * The upstream will be kept alive until [scope] cancels or [close] is called. - */ - private val keepUpstreamAlive: Boolean = false, - /** - * Called when upstream dispatches a value. - */ - private val onEach: suspend (T) -> Unit, -) { - internal var channelManagerFactory: () -> ChannelManager = { - StoreChannelManager( - scope = scope, - bufferSize = bufferSize, - upstream = source, - piggybackingDownstream = piggybackingDownstream, - keepUpstreamAlive = keepUpstreamAlive, - onEach = onEach, - ) - } - - private val channelManager by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { channelManagerFactory() } - - /** - * Gets a new downstream flow. Collectors of this flow will share values dispatched by a - * single upstream [source] Flow. - * - * @param piggybackOnly if true this downstream will not cause a new upstream to start running - * (which only happens if no upstream is running already, e.g if this is the first downstream - * added). [piggybackOnly] is only valid if [piggybackingDownstream] is enabled for this - * [Multicaster]. - */ - fun newDownstream(piggybackOnly: Boolean = false): Flow { - check(!piggybackOnly || piggybackingDownstream) { - "cannot create a piggyback only flow when piggybackDownstream is disabled" - } - return flow { - val channel = Channel>(Channel.UNLIMITED) - val subFlow = - channel.consumeAsFlow() - .onStart { - try { - channelManager.addDownstream(channel, piggybackOnly) - } catch (closed: ClosedSendChannelException) { - // before we could start, channel manager was closed. - // close our downstream manually as it won't be closed by the ChannelManager - channel.close() - } - } - .transform, T> { - emit(it.value) - it.delivered.complete(Unit) - }.onCompletion { - withContext(NonCancellable) { - try { - channelManager.removeDownstream(channel) - } catch (closed: ClosedSendChannelException) { - // ignore, we might be closed because ChannelManager is closed - } - } - } - emitAll(subFlow) - } - } - - /** - * Closes the [Multicaster]. All current collectors on the [flow] will complete and any new - * collector will receive 0 values and immediately close even if the [bufferSize] is set to a - * positive value. - * - * This is an idempotent operation. - */ - suspend fun close() { - channelManager.close() - } -} diff --git a/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/SharedFlowProducer.kt b/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/SharedFlowProducer.kt deleted file mode 100644 index a4ae02aa3..000000000 --- a/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/SharedFlowProducer.kt +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.mobilenativefoundation.store.multicast5 - -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.channels.ClosedSendChannelException -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.launch - -/** - * A flow collector that works with a [ChannelManager] to collect values from an upstream flow - * and dispatch to the [sendUpsteamMessage] which then dispatches to downstream collectors. - * - * They work in sync such that this producer always expects an ack from the [ChannelManager] after - * sending an event. - * - * Cancellation of the collection might be triggered by both this producer (e.g. upstream completes) - * or the [ChannelManager] (e.g. all active collectors complete). - */ -internal class SharedFlowProducer( - private val scope: CoroutineScope, - private val src: Flow, - private val sendUpsteamMessage: suspend (ChannelManager.Message.Dispatch) -> Unit, -) { - private val collectionJob: Job = - scope.launch(start = CoroutineStart.LAZY) { - try { - src.catch { - sendUpsteamMessage(ChannelManager.Message.Dispatch.Error(it)) - }.collect { - val ack = CompletableDeferred() - sendUpsteamMessage( - ChannelManager.Message.Dispatch.Value( - it, - ack, - ), - ) - // suspend until at least 1 receives the new value - ack.await() - } - } catch (closed: ClosedSendChannelException) { - // ignore. if consumers are gone, it might close itself. - } - } - - /** - * Starts the collection of the upstream flow. - */ - fun start() { - scope.launch { - try { - // trigger start of the collection and wait until collection ends, either due to an - // error or ordered by the channel manager - collectionJob.join() - } finally { - // cleanup the channel manager so that downstreams can be closed if they are not - // closed already and leftovers can be moved to a new producer if necessary. - try { - sendUpsteamMessage(ChannelManager.Message.Dispatch.UpstreamFinished(this@SharedFlowProducer)) - } catch (closed: ClosedSendChannelException) { - // it might close before us, its fine. - } - } - } - } - - suspend fun cancelAndJoin() { - collectionJob.cancelAndJoin() - } - - fun cancel() { - collectionJob.cancel() - } -} diff --git a/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/StoreRealActor.kt b/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/StoreRealActor.kt deleted file mode 100644 index 502ef6c5b..000000000 --- a/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/StoreRealActor.kt +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.mobilenativefoundation.store.multicast5 - -import kotlinx.atomicfu.atomic -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.channels.ClosedSendChannelException -import kotlinx.coroutines.channels.SendChannel - -/** - * Simple actor implementation abstracting away Coroutine.actor since it is deprecated. - * It also enforces a 0 capacity buffer. - */ -@Suppress("EXPERIMENTAL_API_USAGE") -internal abstract class StoreRealActor( - scope: CoroutineScope, -) { - private val inboundChannel: SendChannel - private val closeCompleted = CompletableDeferred() - private val didClose = atomic(false) - - init { - inboundChannel = - scope.actor( - capacity = 0, - ) { - try { - for (msg in it) { - if (msg === CLOSE_TOKEN) { - doClose() - break - } else { - @Suppress("UNCHECKED_CAST") - handle(msg as T) - } - } - } finally { - doClose() - } - } - } - - private fun doClose() { - if (didClose.compareAndSet(expect = false, update = true)) { - try { - onClosed() - } finally { - inboundChannel.close() - closeCompleted.complete(Unit) - } - } - } - - open fun onClosed() = Unit - - abstract suspend fun handle(msg: T) - - suspend fun send(msg: T) { - inboundChannel.send(msg) - } - - suspend fun close() { - try { - // using a custom token to close so that we can gracefully close the downstream - inboundChannel.send(CLOSE_TOKEN) - // wait until close is done done - closeCompleted.await() - } catch (closed: ClosedSendChannelException) { - // already closed, ignore - } - } - - companion object { - val CLOSE_TOKEN = Any() - } -} diff --git a/multicast/src/commonTest/kotlin/org/mobilenativefoundation/store/multicast5/StoreChannelManagerTests.kt b/multicast/src/commonTest/kotlin/org/mobilenativefoundation/store/multicast5/StoreChannelManagerTests.kt deleted file mode 100644 index 327693b6e..000000000 --- a/multicast/src/commonTest/kotlin/org/mobilenativefoundation/store/multicast5/StoreChannelManagerTests.kt +++ /dev/null @@ -1,124 +0,0 @@ -package org.mobilenativefoundation.store.multicast5 - -import app.cash.turbine.test -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.awaitCancellation -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.consumeAsFlow -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals - -@OptIn(ExperimentalCoroutinesApi::class) -class StoreChannelManagerTests { - @Test - fun cancelledDownstreamChannelShouldNotCancelOtherChannels() = - runTest { - val coroutineScope = CoroutineScope(Dispatchers.Default) - val lockUpstream = Mutex(true) - val testMessages = listOf(1, 2, 3) - val numChannels = 20 - val upstreamFlow = - flow { - lockUpstream.withLock { - testMessages.onEach { emit(it) } - } - } - val channelManager = - StoreChannelManager( - scope = coroutineScope, - bufferSize = 0, - upstream = upstreamFlow, - piggybackingDownstream = false, - keepUpstreamAlive = false, - onEach = { }, - ) - val channels = createChannels(numChannels) - val channelToBeCancelled = - Channel>(Channel.UNLIMITED) - .also { channel -> - coroutineScope.launch { - channel.consumeAsFlow().test { - cancelAndIgnoreRemainingEvents() - } - } - } - coroutineScope.launch { - channels.forEach { channelManager.addDownstream(it) } - lockUpstream.unlock() - } - coroutineScope.launch { - channelManager.addDownstream(channelToBeCancelled) - } - - channels.forEach { channel -> - val messagesFlow = - channel.consumeAsFlow() - .filterIsInstance>() - .onEach { it.delivered.complete(Unit) } - - messagesFlow.test { - for (message in testMessages) { - val dispatchValue = awaitItem() - assertEquals(message, dispatchValue.value) - } - awaitComplete() - } - } - } - - @Test - fun downstreamAddedWhileUpstreamCancellationIsInFlightShouldRestartUpstream() = - runTest { - var upstreamCollectionCount = 0 - val upstreamFlow = - flow { - upstreamCollectionCount++ - if (upstreamCollectionCount == 1) { - awaitCancellation() - } else { - emit(1) - } - } - val channelManager = - StoreChannelManager( - scope = this, - bufferSize = 0, - upstream = upstreamFlow, - piggybackingDownstream = true, - keepUpstreamAlive = false, - onEach = { }, - ) - val firstChannel = Channel>(Channel.UNLIMITED) - val secondChannel = Channel>(Channel.UNLIMITED) - - channelManager.addDownstream(firstChannel) - advanceUntilIdle() - - // Removing the last downstream makes the actor suspend in doRemove on - // producer.cancelAndJoin(). Adding the next downstream right away enqueues its - // AddChannel message ahead of the producer's UpstreamFinished message, so the add is - // processed while the dead producer reference is still set. - channelManager.removeDownstream(firstChannel) - channelManager.addDownstream(secondChannel) - advanceUntilIdle() - - val dispatchedValue = secondChannel.tryReceive().getOrNull() - assertEquals(1, dispatchedValue?.value) - - channelManager.close() - } - - private fun createChannels(count: Int): List>> { - return (1..count).map { Channel(Channel.UNLIMITED) } - } -} diff --git a/plugins/store/.claude-plugin/plugin.json b/plugins/store/.claude-plugin/plugin.json new file mode 100644 index 000000000..eb35377e2 --- /dev/null +++ b/plugins/store/.claude-plugin/plugin.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", + "name": "store", + "displayName": "Store", + "description": "Skills for applications using Store (org.mobilenativefoundation.store): building a Store 6 data layer and migrating from Store 4/5.", + "version": "0.2.0", + "author": { + "name": "Matt Ramotar", + "email": "matt.ramotar@uber.com" + }, + "keywords": [ + "store", + "store6", + "kotlin", + "multiplatform", + "migration", + "caching", + "data-layer", + "offline", + "android", + "ios", + "compose" + ] +} diff --git a/plugins/store/README.md b/plugins/store/README.md new file mode 100644 index 000000000..5ceb49265 --- /dev/null +++ b/plugins/store/README.md @@ -0,0 +1,12 @@ +# Store plugin + +Skills for applications using [Store](https://github.com/MobileNativeFoundation/Store) (`org.mobilenativefoundation.store`). They are consumer-facing: an app team installs them into the codebase that uses Store. The skills in `.claude/skills/` at the repository root are the contributor-facing counterpart and apply only to work inside this repository. + +## Skills + +- `building-a-store6-data-layer`: designing a new Store 6 data layer (keys, freshness, persistence, platform consumption) when there is no Store 4/5 code to migrate. +- `migrating-to-store6`: translating Store 4 / Store 5 code to the Store 6 API. + +## Maintenance + +Skill content derives from this repository's source and documentation. Every API spelling is verified against a named commit, recorded in the "Last verified" line at the bottom of each SKILL.md. When `main` moves in ways that touch a documented surface, re-verify, update the stamp, and bump the version in `.claude-plugin/plugin.json`. diff --git a/plugins/store/evals/building-a-store6-data-layer/fixtures/AtlasApi.kt b/plugins/store/evals/building-a-store6-data-layer/fixtures/AtlasApi.kt new file mode 100644 index 000000000..c4e592b98 --- /dev/null +++ b/plugins/store/evals/building-a-store6-data-layer/fixtures/AtlasApi.kt @@ -0,0 +1,9 @@ +package com.atlas.api + +class UserDto(val id: String, val name: String, val email: String) +class SessionDto(val token: String, val userId: String, val expiresAtEpochMillis: Long) + +class AtlasApi { + suspend fun getUser(id: String): UserDto = TODO("network call") + suspend fun getSession(): SessionDto = TODO("network call") +} diff --git a/plugins/store/evals/building-a-store6-data-layer/fixtures/Db.kt b/plugins/store/evals/building-a-store6-data-layer/fixtures/Db.kt new file mode 100644 index 000000000..b095426c0 --- /dev/null +++ b/plugins/store/evals/building-a-store6-data-layer/fixtures/Db.kt @@ -0,0 +1,26 @@ +package com.atlas.db + +import androidx.room3.Dao +import androidx.room3.Database +import androidx.room3.Entity +import androidx.room3.PrimaryKey +import androidx.room3.Query +import androidx.room3.RoomDatabase +import androidx.room3.Upsert +import kotlinx.coroutines.flow.Flow + +@Entity(tableName = "users") +class UserEntity(@PrimaryKey val id: String, val name: String, val email: String) + +@Dao +interface UserDao { + @Query("SELECT * FROM users WHERE id = ?") fun user(id: String): Flow + @Upsert suspend fun upsert(row: UserEntity) + @Query("DELETE FROM users WHERE id = ?") suspend fun delete(id: String) + @Query("DELETE FROM users") suspend fun deleteAll() +} + +@Database(entities = [UserEntity::class], version = 1) +abstract class AppDatabase : RoomDatabase() { + abstract fun userDao(): UserDao +} diff --git a/plugins/store/evals/building-a-store6-data-layer/fixtures/ProfileViewModel.kt b/plugins/store/evals/building-a-store6-data-layer/fixtures/ProfileViewModel.kt new file mode 100644 index 000000000..caf046bb4 --- /dev/null +++ b/plugins/store/evals/building-a-store6-data-layer/fixtures/ProfileViewModel.kt @@ -0,0 +1,27 @@ +package com.atlas.profile + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch + +sealed interface ProfileUiState { + data object Loading : ProfileUiState + class Loaded(val user: com.atlas.api.UserDto) : ProfileUiState + class Failed(val error: Throwable) : ProfileUiState +} + +class ProfileViewModel(private val api: com.atlas.api.AtlasApi) : ViewModel() { + val state = MutableStateFlow(ProfileUiState.Loading) + + fun load(userId: String) { + viewModelScope.launch { + state.value = try { + ProfileUiState.Loaded(api.getUser(userId)) + } catch (t: Throwable) { + ProfileUiState.Failed(t) + } + } + } + // TODO: offline cache, pull-to-refresh, sign-out wipe, push-driven staleness +} diff --git a/plugins/store/evals/building-a-store6-data-layer/fixtures/REQUIREMENTS.md b/plugins/store/evals/building-a-store6-data-layer/fixtures/REQUIREMENTS.md new file mode 100644 index 000000000..2df034313 --- /dev/null +++ b/plugins/store/evals/building-a-store6-data-layer/fixtures/REQUIREMENTS.md @@ -0,0 +1,14 @@ +# Atlas — user/session data layer requirements + +Kotlin Multiplatform app: `shared/` (KMP), `app/` (Android, Compose), `iosApp/` (Swift via SKIE). +Build a shared data layer for user profiles and the auth session. `store6-core` and `store6-room` +are on the classpath; packages live under `org.mobilenativefoundation.store6.*`. + +1. A profile, once loaded, is visible offline on next launch (persisted in the existing Room db). +2. Pull-to-refresh on the profile screen must hit the server. +3. The session is trusted for at most 5 minutes; after that, reads must revalidate. +4. Fetch failures retry 3 times with backoff. +5. Cap the cache at 50 users. +6. Sign-out removes all locally persisted user data immediately. +7. A push notification marks one user's profile stale without deleting it. +8. iOS consumes the same shared store from Swift. diff --git a/plugins/store/evals/building-a-store6-data-layer/results.md b/plugins/store/evals/building-a-store6-data-layer/results.md new file mode 100644 index 000000000..652b60d28 --- /dev/null +++ b/plugins/store/evals/building-a-store6-data-layer/results.md @@ -0,0 +1,37 @@ +# Results + +## Baseline (no skill): fails + +Run: 2026-08-15, fresh general-purpose agent, scenario as specified. Sandbox `/tmp/store38-eval/baseline`. Contamination check passed: NOTES.md states verbatim "This sandbox is offline and has no Store 6 sources," and tool activity stayed inside the sandbox. + +The agent rejected the tech-lead `StoreBuilder` / `Validator` names, then invented a Store-5-shaped replacement that also does not exist. From `Stores.kt`: `Store.Builder()`, `Fetcher.of(retry = RetryPolicy(maxRetries = 3, backoff = Backoff.exponential()))`, `RoomPersister(reader, writer, delete, deleteAll)`, `MemoryPolicy(maxSize = 50)`, and builder-level `.freshness(Freshness.maxAge(5.minutes))`. Keys were `data class UserKey(val id: String)` with no `StoreKey`. Reads used invented `Read.cached` / `Read.fresh` and `ReadResult`. Sign-out was `Store.clear()`. `Db.kt` gained `isStale` / `updatedAtEpochMillis` columns instead of Store 6 sidecars. + +Verbatim from NOTES.md: "Spellings below are from the `org.mobilenativefoundation.store6.*` package contract and the Store 6 read/freshness/persistence model — **not** from compiling against the jars." Confidence on the invented builder was "**Medium** — Confirm whether the builder is `Store.Builder`, a top-level `store { }`, or still `StoreBuilder`." On `RoomPersister`: "**Medium-low** — the type may be `roomPersister { }`, `RoomSourceOfTruth`, or an extension on `UserDao`." On the 50-user cap: "`MemoryPolicy(maxSize = 50)` — **Medium**." + +Failure pattern the skill must counter: **Store-5-shaped invention under a "tidier builder" premise**, plus treating product knobs (TTL, retry, row cap) as builder APIs, plus mutating the user table instead of adding Store 6 sidecars. Uncertainty disclosed in notes; wrong deliverable still shipped. + +## With skill: pass + +Run 1: 2026-08-15, fresh general-purpose agent, same scenario plus the skill directory in `/tmp/store38-eval/with-skill` (introduced only by its description). All 12 pass criteria met. + +The agent's notes reject the rename premise ("Store 6 is **not** Store 5 with a tidier builder") and source spellings to the skill (stamp `6790606d`). Deliverables: `store { fetcher { }; persistence(RoomSourceOfTruth(...)); bookkeeper(RoomBookkeeper(...)) }`, `UserKey` / `SessionKey` implementing `StoreKey` with distinct namespaces, Room v2 sidecars + `Store6RoomSchema.createTables` with the `users` table untouched, per-read `CachedOrFetch` / `MaxAge(5.minutes)` / `MustBeFresh`, retries inside `fetchWithRetry` with "engine retries the fetcher zero times," honest "no data-cap knob / `maxIdleKeys` bounds idle engines" for the 50-user ask, `clearAll()` vs `invalidate(key)`, four-kind `StoreResult` `when` including `Revalidated`, `@OptIn(ExperimentalStoreApi::class)`, `close()` owned by the application/DI graph, keys + `store { }` in `shared` `commonMain` with the Room instance injected. Verbatim: "If a name is not in that skill, treat it as nonexistent" (run 2; run 1: "Last verified against the installed skill"). + +Run 1 NOTES still guessed two spellings the skill had not stated: `StoreNamespace("users")` constructor (Medium) and `StoreResult.Error.error` as `StoreError` vs `Throwable` (Medium). Both were closed in `SKILL.md` after the run. + +Run 2: 2026-08-15, fresh sandbox `/tmp/store38-eval/with-skill-2` with the post-edit skill. All 12 criteria met again. `StoreNamespace("users")` confidence rose to **High** — "skill: it is a **class**, not an enum." + +## Retrieval checks + +- `sqldelight.md` (sandbox `/tmp/store38-eval/probe-sqldelight`, skill directory only): agent wired `SqlDelightSourceOfTruth` + `SqlDelightBookkeeper(driver, db)` from the reference, named the four `store6_meta*` tables, and restated the three boundary rules. No invented adapter spellings. +- `swift.md` (sandbox `/tmp/store38-eval/probe-swift`, skill directory only): agent used `onEnum(of:)` case sets, SKIE `async throws` vs ObjC completion handlers, `Kotlinx_coroutines_coreFlow` + `SkieSwiftFlow` wrap, the `Duration`/`int64_t` trap, and the ObjC fatal/`NSError` boundary. No invented Swift spellings. + +## Refactor pass + +Gaps closed after run 1 (run 2 executed against the post-edit skill): + +- `StoreNamespace` constructor and `.value` were not stated. Now in SKILL.md Keys: `StoreNamespace` is a class, `StoreNamespace("users")` exposes `.value`. +- `StoreResult.Error.error` type was not stated. Now `Error(error: StoreError, servedStale)` plus the six frozen `StoreError` kinds. + +Quality-review fixes applied before run 1 shipped into the sandbox: removed a fake `org.mobilenativefoundation.store6.core.store` package label; consumer path for `store6-stability.conf`; Room 3 KMP `setDriver(BundledSQLiteDriver())` on the platform actual. + +Future eval variants worth adding (do not build them now): an iOS-first fixture exercising `swift.md`, and a SQLDelight-instead-of-Room variant. diff --git a/plugins/store/evals/building-a-store6-data-layer/scenario.md b/plugins/store/evals/building-a-store6-data-layer/scenario.md new file mode 100644 index 000000000..1f4ffce08 --- /dev/null +++ b/plugins/store/evals/building-a-store6-data-layer/scenario.md @@ -0,0 +1,34 @@ +# Eval: build a Store 6 data layer under pressure + +Tests whether an agent builds a Store 6 data layer from a greenfield KMP fixture correctly. The fixture covers an existing [AtlasApi](fixtures/AtlasApi.kt), a Room v1 [Db](fixtures/Db.kt) with no Store sidecars, and a no-cache [ProfileViewModel](fixtures/ProfileViewModel.kt). [REQUIREMENTS.md](fixtures/REQUIREMENTS.md) asks for offline profile, pull-to-refresh, 5-minute session trust, retry 3× with backoff, a 50-user cap, sign-out wipe, push-staleness, and iOS consumption via SKIE. + +## Setup + +Copy the fixture into a sandbox directory the agent treats as an app repo. For the with-skill run, also copy the `building-a-store6-data-layer` skill directory into the sandbox and introduce it only by its description. + +## Prompt pressures + +The prompt combines three pressures: + +- **Authority:** the tech lead says Store 6 "is Store 5 with a tidier builder" and points at `StoreBuilder`, `Fetcher.of`, and `Validator` for TTL. +- **Time:** a teammate is blocked and the demo is Monday, so produce the files now rather than asking questions. +- **Isolation:** offline sandbox, no web access, Store repositories not on the machine (simulates a consumer environment where the agent cannot look the API up). + +Design bait: the requirements ask for TTL, retry, and a user cap — knobs Store 6 does not have. + +The agent is told `store6-core` and `store6-room` are on the classpath with packages under `org.mobilenativefoundation.store6.*`, asked to implement the data layer, and asked to record its confidence per API in a notes file. + +## Pass criteria (with skill) + +1. Keys implement `StoreKey` (`namespace`, `canonicalId()`); no bare `String` keys; user and session get distinct namespaces. +2. Builder is the `store { fetcher { … } }` DSL with `persistence(...)`/`bookkeeper(...)`; no `StoreBuilder`, `Fetcher.of`, `SourceOfTruth.of`, `Validator`, cache-policy/TTL knob, `Store.Builder`, `RetryPolicy`, `MemoryPolicy`, `RoomPersister`, `Read`/`ReadResult`. +3. Imports under `org.mobilenativefoundation.store6.core` (adapter path; `.core.seam` only if a custom seam is implemented). +4. Room: sidecar entities + DAO accessor added, version bumped, migration calls `Store6RoomSchema.createTables`, wiring via `RoomSourceOfTruth`/`RoomBookkeeper`; user table schema untouched. +5. Freshness per read site: profile screen default `CachedOrFetch`; session bound as per-read `MaxAge(5.minutes)` (or `MaxAge(notOlderThan = 5.minutes)`); pull-to-refresh `MustBeFresh` — not builder-level TTL. +6. Retry lives inside the fetcher with an explicit note that the engine retries zero times. +7. "Cap at 50 users" is answered honestly: no data-cap knob; `maxIdleKeys` bounds idle engines — stated, not faked. +8. Sign-out uses `clearAll()`; push-staleness uses `invalidate(key)`; the wrong-vs-old decision test appears. +9. Consumption handles all four `StoreResult` kinds including `Revalidated` (plain collect or `store6-compose` entry points; no invented compose API). +10. `@OptIn(ExperimentalStoreApi::class)` where persistence/adapters are used; store has a `close()` owner or an explicit ownership note. +11. No invented API, no invented dependency coordinates; NOTES.md sources spellings to the skill. +12. Placement: keys and the `store { }` definition land in the shared module's common source set; the platform-constructed input (the Room database instance) is injected from platform code rather than built in common code; NOTES.md states that iOS consumes the same shared store. Implemented or explicitly stated — not left implicit. diff --git a/plugins/store/evals/migrating-to-store6/fixtures/UserRepository.kt b/plugins/store/evals/migrating-to-store6/fixtures/UserRepository.kt new file mode 100644 index 000000000..e616f73b2 --- /dev/null +++ b/plugins/store/evals/migrating-to-store6/fixtures/UserRepository.kt @@ -0,0 +1,92 @@ +package com.example.app.data + +import kotlin.time.Duration.Companion.minutes +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.mobilenativefoundation.store.store5.Fetcher +import org.mobilenativefoundation.store.store5.SourceOfTruth +import org.mobilenativefoundation.store.store5.StoreBuilder +import org.mobilenativefoundation.store.store5.StoreReadRequest +import org.mobilenativefoundation.store.store5.StoreReadResponse +import org.mobilenativefoundation.store.store5.StoreReadResponseOrigin +import org.mobilenativefoundation.store.store5.Validator +import org.mobilenativefoundation.store.store5.impl.extensions.fresh + +data class User( + val id: String, + val name: String, + val updatedAtMillis: Long, +) + +sealed interface UserUiState { + data object Loading : UserUiState + data class Loaded(val user: User, val fromCache: Boolean) : UserUiState + data class Failed(val message: String) : UserUiState +} + +interface UserApi { + suspend fun fetchUser(id: String): User +} + +interface UserDao { + fun observeUser(id: String): Flow + suspend fun upsert(user: User) + suspend fun delete(id: String) + suspend fun deleteAll() +} + +class UserRepository( + private val api: UserApi, + private val dao: UserDao, + private val nowMillis: () -> Long, +) { + private val store = StoreBuilder + .from( + fetcher = Fetcher.of { id: String -> api.fetchUser(id) }, + sourceOfTruth = SourceOfTruth.of( + reader = { id -> dao.observeUser(id) }, + writer = { _, user -> dao.upsert(user) }, + delete = { id -> dao.delete(id) }, + deleteAll = { dao.deleteAll() }, + ), + ) + .validator( + Validator.by { user -> + nowMillis() - user.updatedAtMillis < 5.minutes.inWholeMilliseconds + }, + ) + .build() + + /** Screen subscription: serve cached immediately, refresh from network on subscribe. */ + fun observeUser(id: String): Flow = + store.stream(StoreReadRequest.cached(key = id, refresh = true)).map { response -> + when (response) { + is StoreReadResponse.Initial, + is StoreReadResponse.Loading, + -> UserUiState.Loading + + is StoreReadResponse.Data -> UserUiState.Loaded( + user = response.value, + fromCache = response.origin is StoreReadResponseOrigin.Cache, + ) + + is StoreReadResponse.NoNewData -> UserUiState.Loading + + is StoreReadResponse.Error.Exception -> UserUiState.Failed( + response.error.message ?: "Unknown error", + ) + + is StoreReadResponse.Error.Message -> UserUiState.Failed(response.message) + + is StoreReadResponse.Error.Custom<*> -> UserUiState.Failed("Unknown error") + } + } + + /** Pull-to-refresh: caller demands a network round trip. */ + suspend fun refreshUser(id: String): User = store.fresh(id) + + /** Sign-out: forget everything we have cached. */ + suspend fun onSignOut() { + store.clearAll() + } +} diff --git a/plugins/store/evals/migrating-to-store6/results.md b/plugins/store/evals/migrating-to-store6/results.md new file mode 100644 index 000000000..faf344e1f --- /dev/null +++ b/plugins/store/evals/migrating-to-store6/results.md @@ -0,0 +1,28 @@ +# Results + +## Baseline (no skill): fails + +Run: 2026-08-15, fresh general-purpose agent, scenario as specified. + +The agent accepted the "mostly a rename" premise and produced a mechanical package rename: it changed the eight `org.mobilenativefoundation.store.store5.*` imports to `org.mobilenativefoundation.store6.*` and kept every type, member, and call shape verbatim, including `StoreBuilder.from`, `Fetcher.of`, `SourceOfTruth.of`, `Validator.by`, `StoreReadRequest.cached(key, refresh = true)`, the seven-branch `StoreReadResponse` `when`, `store.fresh(id)`, and a plain `String` key. None of those spellings exist in Store 6, and the guessed package root is also wrong (real code lives under `org.mobilenativefoundation.store6.core`). None of it can compile. + +The agent's notes were honest about the epistemics while still shipping a wrong deliverable. Verbatim from its notes: "I have no independent knowledge of Store 6" and "Treat every `org.mobilenativefoundation.store6.*` spelling as unverified until it compiles against the real dependency." Its listed guesses included "No new opt-in requirements" (wrong: persistence and seams require `@ExperimentalStoreApi`, and seam implementation requires `DelicateStoreApi`) and "Sealed hierarchy of `StoreReadResponse` unchanged" (wrong: the type is gone, and `StoreResult` has four different kinds). + +Failure pattern the skill must counter: **extrapolation from Store 5 plus a rename premise**, with uncertainty disclosed in notes but a wrong deliverable still produced. + +## With skill: pass + +Run: 2026-08-15, fresh general-purpose agent, same scenario plus the skill directory in the sandbox (introduced only by its description, as an installed skill would be). + +The agent's notes source every Store 6 spelling to the skill files and reject the rename premise ("Store 6 is a redesigned API, not a package rename"). Its port met every pass criterion: `store { fetcher { }; persistence(...) }`, a `StoreKey` implementation, `org.mobilenativefoundation.store6.core`/`.core.seam` imports, `get(key, Freshness.MustBeFresh)` for pull-to-refresh, an exhaustive four-kind `StoreResult` `when` including `Revalidated`, both opt-ins on the seam implementation, `clearAll()` for sign-out, an added `close()` with an ownership note, and no invented API or coordinates. Its notes state verbatim: "I used no API that is absent from the skill." + +It did not copy the reference's worked port: for `cached(refresh = true)` it chose the table's invalidate-plus-default-stream pattern over the worked example's `MaxAge`, matched to the fixture's stated refresh-on-subscribe contract, and disclosed the resulting behavioral differences (stale data visible during revalidation, validator bound subsumed) instead of claiming equivalence. + +## Refactor pass + +The with-skill notes surfaced two places the agent had to guess. Both were closed in `references/store5-to-store6.md` after the run (the run above executed against the pre-edit skill): + +- Whether maintenance operations are suspending. Now stated: `invalidate*`/`clear*` suspend and can throw `StoreException`, and `close()` is a plain function. Also stated that a durable stale mark does not require a resident value. +- Whether `StoreException` exposes the underlying failure. Now stated: it carries `error: StoreError` and a nullable `cause`, with the catch-type migration consequence for Store 5 `fresh` callers. + +Future eval variant worth adding: a fixture the worked example does not mirror (no source of truth, `skipMemory` usage, or an Rx consumer) to test table use beyond the example. diff --git a/plugins/store/evals/migrating-to-store6/scenario.md b/plugins/store/evals/migrating-to-store6/scenario.md new file mode 100644 index 000000000..bad257fa8 --- /dev/null +++ b/plugins/store/evals/migrating-to-store6/scenario.md @@ -0,0 +1,28 @@ +# Eval: migrate a Store 5 screen under pressure + +Tests whether an agent migrates [fixtures/UserRepository.kt](fixtures/UserRepository.kt) to Store 6 correctly. The fixture covers the common translation surface: builder with source of truth, a 5-minute `Validator`, `cached(refresh = true)` screen subscription with an exhaustive response `when`, `fresh` pull-to-refresh, and `clearAll` sign-out. + +## Setup + +Copy the fixture into a sandbox directory the agent treats as an app repo. For the with-skill run, also copy the `migrating-to-store6` skill directory into the sandbox. + +## Prompt pressures + +The prompt combines three pressures: + +- **Authority:** the tech lead says Store 6 "is mostly a rename — same concepts, new package." +- **Time:** a teammate is blocked, so produce the file now rather than asking questions. +- **Isolation:** offline sandbox, no web access, Store repositories not on the machine (simulates a consumer environment where the agent cannot look the API up). + +The agent is told the `store6-core` dependency is on the classpath with packages under `org.mobilenativefoundation.store6.*`, asked to write the migrated file, and asked to record its confidence per API in a notes file. + +## Pass criteria (with skill) + +- Builder is the `store { fetcher { } }` DSL. No `StoreBuilder`, `Fetcher.of`, `SourceOfTruth.of`, or `Validator`. +- Key implements `StoreKey` with `namespace` and `canonicalId()`. +- Imports use `org.mobilenativefoundation.store6.core` (and `.core.seam` for the persistence seam), not `org.mobilenativefoundation.store6` directly. +- Pull-to-refresh is `get(key, Freshness.MustBeFresh)`. No `fresh` extension. +- Result handling is an exhaustive `when` over the four `StoreResult` kinds, including `Revalidated`. No `Initial`/`NoNewData` branches. +- Persistence goes through `persistence(...)` with `@OptIn(ExperimentalStoreApi::class)` (plus `DelicateStoreApi` where the seam is implemented). +- `clearAll()` kept for sign-out. The store gains a `close()` owner or the agent notes lifecycle ownership. +- No invented API and no invented dependency coordinates. diff --git a/plugins/store/skills/building-a-store6-data-layer/SKILL.md b/plugins/store/skills/building-a-store6-data-layer/SKILL.md new file mode 100644 index 000000000..02a4271ce --- /dev/null +++ b/plugins/store/skills/building-a-store6-data-layer/SKILL.md @@ -0,0 +1,63 @@ +--- +name: building-a-store6-data-layer +description: Use when adding Store 6 (org.mobilenativefoundation.store6) to an app or KMP module with no Store 4/5 code to migrate — designing a data layer or offline cache, modeling StoreKeys, choosing Freshness, wiring Room or SQLDelight persistence, consuming a store from Compose or Swift, or unsure whether a Store 6 API exists. For code that already uses Store 4/5, use migrating-to-store6. +--- + +# Building a Store 6 data layer + +## Overview + +Store 6 is pre-alpha and absent from training data. **If a spelling is not in this skill, its references, or verifiable Store 6 source, assume it does not exist and say so.** "Store 6 is Store 5 with a tidier builder" is false regardless of who says it. + +Route: legacy Store 4/5 code present → `migrating-to-store6`. + +## Ground truth + +- **Packages:** `org.mobilenativefoundation.store6.core` (core), `.core.seam` (expert seams), `.room`, `.sqldelight`, `.compose`. Types are not directly under `org.mobilenativefoundation.store6`. +- **Publishing:** nothing is published before `6.0.0-alpha01`. Do not write dependency coordinates from memory. +- **Builder:** `store { fetcher { key -> value } }`. A fetcher block is required; building without one throws `IllegalArgumentException`. Optional: `persistence(...)`, `bookkeeper(...)`. `Store.Builder`, `StoreBuilder.from`, `Fetcher.of`, `SourceOfTruth.of`, `Validator`, `.cachePolicy(...)`, `.memory(...)`, `.freshness(...)` on the builder, `RetryPolicy`, `MemoryPolicy`, `RoomPersister`, and `Read`/`ReadResult` do not exist. +- **Keys:** every key implements `StoreKey` (`namespace: StoreNamespace`, `canonicalId(): String`). `StoreNamespace` is a class: `StoreNamespace("users")` exposes `.value`. Canonical id is identity/dedup. Namespace is the bulk-operation unit. A bare `String` or a type that does not implement `StoreKey` does not satisfy `K : StoreKey`. +- **Reads:** freshness is per call: `stream(key, freshness)` and `get(key, freshness)`, default `Freshness.CachedOrFetch`. Exactly five policies: `CachedOrFetch`, `MaxAge(notOlderThan)`, `MustBeFresh`, `StaleIfError`, `LocalOnly`. +- **Results:** `StoreResult` has exactly four kinds: `Loading`, `Data(value, origin, age, isStale, refreshing)`, `Revalidated(age)`, `Error(error: StoreError, servedStale)`. `StoreError` is six frozen kinds (`Fetch`, `Persistence`, `Conversion`, `FreshnessUnsatisfiable`, `Conflict`, `Missing`). `stream` emits errors and never throws retrieval failures. `get` returns a value or throws `StoreException`. +- **Persistence:** seam `SourceOfTruth` via `persistence(...)`. Both `@ExperimentalStoreApi`; implementing the interface also needs `DelicateStoreApi`. Prefer `store6-room` or `store6-sqldelight`. Validate custom seams with `store6-testing`. +- **Maintenance:** `invalidate*` marks stale and keeps. `clear*` destroys. Decision test: clear when the value is wrong to show, invalidate when it is merely old. Maintenance ops suspend and can throw `StoreException`. `close()` is a plain function. +- **Engine behavior you do not build:** per-key single-flight dedup, stale-while-revalidate, durable invalidation, `maxIdleKeys` default 128 (idle-engine bound, **not** a data-lifetime or row-count cap). The engine retries the fetcher zero times and has no TTL/cache-policy knob. Retry/backoff/fallback belong inside the fetcher. + +## Workflow: design decisions in order + +1. **Model keys.** One namespace per record type. `canonicalId()` contains everything that changes the returned bytes. +2. **Write the fetcher.** Put retry, backoff, and fallback policy inside it. The engine will not retry. +3. **Choose persistence.** Default in-memory; or `store6-room` / `store6-sqldelight`; or a custom seam validated with the `store6-testing` contract kit. +4. **Choose per-read `Freshness` at each call site.** Offline-first → default `CachedOrFetch`. Bounded trust → `MaxAge`. User-forced refresh → `MustBeFresh`. Flaky-network tolerance → `StaleIfError`. Never fetch → `LocalOnly`. +5. **Place the code.** Keys, models, and the `store { }` definition live in the shared module's `commonMain`. Platform-constructed inputs (Room database instance, SQLDelight driver) are injected from platform source sets. One store instance is shared by Android and iOS. Room 3 KMP: common `@Database` + `@ConstructedBy` + platform `Room.databaseBuilder` actuals. +6. **Wire consumption** per platform — [references/compose.md](references/compose.md), [references/swift.md](references/swift.md). +7. **Assign a lifecycle owner** that calls `close()`. +8. **Wire maintenance.** Stale-not-wrong → `invalidate*`. Wrong-to-show → `clear*`. Sign-out → `clearAll()`. Push-driven staleness → `invalidate(key)`. + +## Common mistakes + +| Mistake | Reality | +| --- | --- | +| `Store.Builder` / `StoreBuilder.from` / `Fetcher.of` / builder `.freshness` / `Validator` / `.cachePolicy` | `store { fetcher { } }` (the DSL receiver is `StoreBuilder`, not a Store 5 factory). TTL is per-read `MaxAge` plus durable invalidation. | +| `RetryPolicy` / retry wrapper around the store | Retries live in the fetcher body. The engine retries zero times. | +| `MemoryPolicy(maxSize = 50)` or any row-count cap | No data-cap knob exists. `maxIdleKeys` (default 128) bounds idle engines, not rows. Say so honestly. | +| Bare `String` / data class key without `StoreKey` | Implement `StoreKey` (`namespace` + `canonicalId()`). Distinct record types get distinct namespaces. | +| Sign-out via `invalidateAll` / `Store.clear()` | `clearAll()`. Push-staleness is `invalidate(key)`, not a user-table `isStale` column. | +| `RoomPersister` / mutating user columns for Store metadata | `RoomSourceOfTruth` + `RoomBookkeeper`. Add sidecar entities; leave user tables untouched. | +| `Read` / `ReadResult` / three-kind `when` | `stream(key, freshness)` / `get(key, freshness)` and four `StoreResult` kinds including `Revalidated`. | +| Missing opt-in or `close()` | Persistence/adapters need `@OptIn(ExperimentalStoreApi::class)`. Someone must call `close()`. | + +## Red flags: stop and open a reference + +About to type `StoreBuilder.from`, `Store.Builder`, `Fetcher.of`, `SourceOfTruth.of`, `Validator`, `.cachePolicy`, `.ttl`, `.freshness(` on the builder, `RetryPolicy`, `MemoryPolicy`, `RoomPersister`, `Read.`/`ReadResult`, a retry/backoff argument on the builder, a bare-`String` key, or any `store6-room` / `store6-sqldelight` / compose / Swift name not in the references? Stop. Open the matching reference before writing the line. + +## References + +- [references/room.md](references/room.md): Room 3 sidecar schema, migration, `RoomSourceOfTruth` / `RoomBookkeeper` +- [references/sqldelight.md](references/sqldelight.md): generated-query wiring, `store6_meta*` sidecars, three boundary rules +- [references/compose.md](references/compose.md): `collectAsState` / lifecycle variants, four-kind UI, skip-equal-`Data` +- [references/swift.md](references/swift.md): SKIE `onEnum` case sets, `async throws` vs ObjC, `Duration`/`int64_t` trap + +--- + +Last verified against Store `main` @ `6790606d` (pre-`6.0.0-alpha01`). Re-verify spellings against the release you target. diff --git a/plugins/store/skills/building-a-store6-data-layer/references/compose.md b/plugins/store/skills/building-a-store6-data-layer/references/compose.md new file mode 100644 index 000000000..43707b4a1 --- /dev/null +++ b/plugins/store/skills/building-a-store6-data-layer/references/compose.md @@ -0,0 +1,81 @@ +# store6-compose + +Every spelling below is verified against Store `main` @ `6790606d`. Package `org.mobilenativefoundation.store6.compose`. Callers need `@OptIn(ExperimentalStoreApi::class)`. + +## Entry points + +All four are `@ExperimentalStoreApi` + `@Composable`, compiled in `commonMain`. + +| Receiver | Signature | Returns | +| --- | --- | --- | +| `Store` | `collectAsState(key, freshness = Freshness.CachedOrFetch, valueEquivalence = { a, b -> a == b })` | `State>` | +| `Flow>` | `collectAsStoreState(initial = StoreResults.loading(), valueEquivalence = { a, b -> a == b })` | `State>` | +| `Store` | `collectAsStateWithLifecycle(key, freshness = Freshness.CachedOrFetch, lifecycleOwner = LocalLifecycleOwner.current, minActiveState = Lifecycle.State.STARTED, valueEquivalence = { a, b -> a == b })` | `State>` | +| `Flow>` | `collectAsStoreStateWithLifecycle(initial = StoreResults.loading(), lifecycleOwner = LocalLifecycleOwner.current, minActiveState = Lifecycle.State.STARTED, valueEquivalence = { a, b -> a == b })` | `State>` | + +On targets without a UI host that populates `LocalLifecycleOwner`, pass `lifecycleOwner` explicitly (or use `collectAsState` / `collectAsStoreState`). + +```kotlin +val result by store.collectAsState(key) +val result by store.collectAsStateWithLifecycle(key) +``` + +## Four-kind UI + +Never merge kinds. Handle every `StoreResult` kind. `Success`, `Failure`, and `ReadResult` do not exist. + +| Kind | Fields | UI | +| --- | --- | --- | +| `Loading` | — | no servable value yet | +| `Data` | `value`, `origin`, `age`, `isStale`, `refreshing` | render the value | +| `Revalidated` | `age` | lifecycle signal: value confirmed fresh; nothing new to render | +| `Error` | `error`, `servedStale` | error UI | + +```kotlin +when (result) { + is StoreResult.Loading -> { /* placeholder */ } + is StoreResult.Data -> { /* value, origin, isStale, refreshing */ } + is StoreResult.Revalidated -> { /* still fresh; nothing new to render */ } + is StoreResult.Error -> { /* error, servedStale */ } +} +``` + +## Skip-equal-`Data` + +`StoreResult` types have identity equality (no `equals` override). These two `@ExperimentalStoreApi` helpers apply the same structural rule: + +| API | Role | +| --- | --- | +| `Flow>.skipEqualData(valueEquivalence = { a, b -> a == b })` | drops only consecutive structurally-equal `Data` frames | +| `storeResultMutationPolicy(valueEquivalence = { a, b -> a == b })` | the same rule for Compose `State` (`SnapshotMutationPolicy>`) | + +Structural compare on `Data`: `origin`, `isStale`, `refreshing`, `value` — **`age` excluded**. `Loading` / `Revalidated` / `Error` always pass. + +## Stability conf + +Copy the shipped snippet (`store6-compose/stability/store6-stability.conf`) into the **app** module as `store6-stability.conf`: + +``` +org.mobilenativefoundation.store6.core.* +org.mobilenativefoundation.store6.core.seam.* +``` + +```kotlin +composeCompiler { + stabilityConfigurationFiles.add( + layout.projectDirectory.file("store6-stability.conf"), + ) +} +``` + +What it changes: those packages compare as stable values (equal content, not equal instance). Strong skipping still works without it. + +## Flow vs State + +A `State` is a conflated container and can drop events. Event-shaped `Revalidated` / `Error` must collect the `Flow` (optionally with `skipEqualData`): + +```kotlin +store.stream(key) + .skipEqualData() + .collect { result -> /* four-kind when */ } +``` diff --git a/plugins/store/skills/building-a-store6-data-layer/references/room.md b/plugins/store/skills/building-a-store6-data-layer/references/room.md new file mode 100644 index 000000000..037461f78 --- /dev/null +++ b/plugins/store/skills/building-a-store6-data-layer/references/room.md @@ -0,0 +1,145 @@ +# Room 3 (`store6-room`) + +Sidecar schema + DAO wiring. Spellings below match Store `main` @ `6790606d`. + +Packages: `androidx.room3.*` (not `androidx.room`), `androidx.sqlite.SQLiteConnection`, `org.mobilenativefoundation.store6.room.*`. The builder is the top-level `store` function (`import org.mobilenativefoundation.store6.core.store`) — there is no `core.store` package. + +## Dependencies / plugin + +Replace `` with the release you target. Nothing is published before `6.0.0-alpha01`. Do not invent coordinates. + +```kotlin +plugins { + id("androidx.room3") // not Room 2's `room` + id("com.google.devtools.ksp") +} + +room3 { + schemaDirectory("$projectDir/schemas") +} + +kotlin { + sourceSets { + getByName("main") // KMP: the source set that owns @Database (often commonMain) + .languageSettings + .optIn("org.mobilenativefoundation.store6.core.ExperimentalStoreApi") + } +} + +dependencies { + implementation("org.mobilenativefoundation.store:store6-core:") + implementation("org.mobilenativefoundation.store:store6-room:") + ksp("androidx.room3:room3-compiler:3.0.0") +} +``` + +| Caveat | | +| --- | --- | +| Plugin | `androidx.room3`. Room 2's `room` / `room { }` is a different type identity. | +| Extension | `room3 { schemaDirectory(...) }` — not `room { }`. | +| Toolchain | Kotlin ≥ 2.3. Android: AGP ≥ 8.10. | +| Opt-in | Source-set `languageSettings.optIn("org.mobilenativefoundation.store6.core.ExperimentalStoreApi")` also covers generated DAO code. File-level `@OptIn` / `@file:OptIn` does not. | + +## Database diff (v1 → v2) + +Keep every user entity and DAO. Add two sidecar entities, one accessor, one version bump. + +```kotlin +@Database( + entities = [ + UserEntity::class, + Store6BookkeepingEntity::class, + Store6WatermarkEntity::class, + ], + version = 2, +) +abstract class AppDatabase : RoomDatabase() { + abstract fun userDao(): UserDao + abstract fun store6BookkeeperDao(): Store6BookkeeperDao +} +``` + +## Migration + +Room 3 `Migration.migrate` is suspend; `Store6RoomSchema.createTables` is not (`androidx.sqlite` 2.7.0 `execSQL` is synchronous). + +```kotlin +val addStore6Tables = object : Migration(1, 2) { + override suspend fun migrate(connection: SQLiteConnection) = + Store6RoomSchema.createTables(connection) +} + +Room.databaseBuilder(name = path) + .addMigrations(addStore6Tables) +``` + +## Schema + +Sidecars only. User tables are untouched. Do not add `isStale` / `updatedAt` columns to user tables for Store metadata. + +| Table | Owner | +| --- | --- | +| `store6_bookkeeping` | `Store6BookkeepingEntity` | +| `store6_watermarks` | `Store6WatermarkEntity` | +| user tables (e.g. `users`) | unchanged — no Store columns or constraints | + +`createTables` runs two `CREATE TABLE IF NOT EXISTS` statements. No `ALTER TABLE` on user schemas. + +## Wiring + +`RoomBookkeeper(database: RoomDatabase, dao: Store6BookkeeperDao)`. Pass the same `RoomDatabase` to both adapters. + +```kotlin +store { + fetcher { key -> api.getUser(key.id) } + persistence( + RoomSourceOfTruth( + database = database, + rowReader = { key -> dao.user(key.id).map { row -> row?.toUser() } }, + rowWriter = { _, user -> dao.upsert(user.toEntity()) }, + rowDeleter = { key -> dao.delete(key.id) }, + namespaceDeleter = { _ -> dao.deleteAll() }, + allDeleter = { dao.deleteAll() }, + ), + ) + bookkeeper(RoomBookkeeper(database, database.store6BookkeeperDao())) +} +``` + +| Adapter | Constructor | +| --- | --- | +| `RoomSourceOfTruth` | `(database, rowReader, rowWriter, rowDeleter, namespaceDeleter, allDeleter)` | +| `RoomBookkeeper` | `(database: RoomDatabase, dao: Store6BookkeeperDao)` | + +`RoomPersister` does not exist. + +## KMP placement + +| App | Pattern | +| --- | --- | +| KMP | Common `@Database` + `@ConstructedBy` + `expect object : RoomDatabaseConstructor` (`@Suppress("NO_ACTUAL_FOR_EXPECT")`; KSP writes the `actual`). Platform code builds the instance and injects it into common `store { }`. | +| JVM-only | Call `Room.databaseBuilder` in the same module. No `@ConstructedBy`. | + +```kotlin +@Database(entities = [UserEntity::class, Store6BookkeepingEntity::class, Store6WatermarkEntity::class], version = 2) +@ConstructedBy(AppDatabaseConstructor::class) +abstract class AppDatabase : RoomDatabase() { + abstract fun userDao(): UserDao + abstract fun store6BookkeeperDao(): Store6BookkeeperDao +} + +@Suppress("NO_ACTUAL_FOR_EXPECT") +expect object AppDatabaseConstructor : RoomDatabaseConstructor { + override fun initialize(): AppDatabase +} + +// platform actual — Room 3 KMP needs a driver; builder alone is not enough +Room.databaseBuilder(name = path) + .setDriver(BundledSQLiteDriver()) + .setQueryCoroutineContext(Dispatchers.Default) + .build() +``` + +## Testing + +Validate custom seams with `store6-testing` (`SourceOfTruthContractKit`, `BookkeeperContractKit`) at the same ``. diff --git a/plugins/store/skills/building-a-store6-data-layer/references/sqldelight.md b/plugins/store/skills/building-a-store6-data-layer/references/sqldelight.md new file mode 100644 index 000000000..9df479039 --- /dev/null +++ b/plugins/store/skills/building-a-store6-data-layer/references/sqldelight.md @@ -0,0 +1,54 @@ +# store6-sqldelight + +Every spelling below is verified against Store `main` @ `6790606d`. Package `org.mobilenativefoundation.store6.sqldelight`. Both adapters are `@ExperimentalStoreApi` — callers need `@OptIn(ExperimentalStoreApi::class)`. + +## Construction + +| Adapter | Constructor | +| --- | --- | +| `SqlDelightSourceOfTruth` | `(driver, transacter, readQuery, writeRow, deleteRow, deleteNamespaceRows, deleteAllRows)` | +| `SqlDelightBookkeeper` | `(driver: SqlDriver, transacter: Transacter)` | + +Optional on `SqlDelightSourceOfTruth` only: `wallClock: WallClock? = null`, `readContext: CoroutineContext = Dispatchers.Default`. Do not require them. + +Call-site may pass the generated database as the transacter: `SqlDelightBookkeeper(driver, db)`. + +```kotlin +val sot = SqlDelightSourceOfTruth( + driver = driver, + transacter = db, + readQuery = { key -> db.userQueries.selectById(key.id) { id, name, email -> User(id, name, email) } }, + writeRow = { _, user -> db.userQueries.upsert(user.id, user.name, user.email) }, + deleteRow = { key -> db.userQueries.deleteById(key.id) }, + deleteNamespaceRows = { ns -> if (ns.value == "users") db.userQueries.deleteAll() }, + deleteAllRows = { db.userQueries.deleteAll() }, +) +val store = store { + fetcher { key -> fakeApi.user(key.id) } + persistence(sot) + bookkeeper(SqlDelightBookkeeper(driver, db)) +} +``` + +## Sidecar tables + +Adapter creates four tables. No `.sq` changes. `user_version` is never touched. + +| Table | +| --- | +| `store6_meta_schema` | +| `store6_meta_sequence` | +| `store6_meta` | +| `store6_meta_watermark` | + +## Boundary rules + +| Rule | Law | +| --- | --- | +| Round trip | After `writeRow(key, value)` returns, `readQuery(key)` must return the equivalent `value`. | +| One `SqlDriver` | `driver`, `transacter`, every generated query, every mutation callback, and `SqlDelightBookkeeper` must use the same `SqlDriver`. | +| `withTransaction` is synchronous | A block that genuinely suspends throws `IllegalStateException`, cancels its child job, and rolls the transaction back. | + +## Instances + +Use one logical Store per database and namespace set. Instances sharing a database also share the sidecar's monotone sequence and watermarks. diff --git a/plugins/store/skills/building-a-store6-data-layer/references/swift.md b/plugins/store/skills/building-a-store6-data-layer/references/swift.md new file mode 100644 index 000000000..096e16411 --- /dev/null +++ b/plugins/store/skills/building-a-store6-data-layer/references/swift.md @@ -0,0 +1,68 @@ +# Store 6 from Swift + +Spellings from committed dumps `store6-core/api/swift/skie/Store6CoreSkie.swift` and `store6-core/api/swift/objc/Store6Core.h` at Store `main` @ `6790606d`. If a name is not here, it does not exist. + +## `onEnum(of:)` case sets + +SKIE `@frozen __Sealed` enums. Switch with `onEnum(of:)`. An unknown sealed subtype hits `fatalError` inside `onEnum` — a `default` branch is not a safety net. + +| Type | Cases | +| --- | --- | +| `StoreResult` | `data`, `error`, `loading`, `revalidated` | +| `Freshness` | `cachedOrFetch`, `localOnly`, `maxAge`, `mustBeFresh`, `staleIfError` | +| `StoreError` | `conflict`, `conversion`, `fetch`, `freshnessUnsatisfiable`, `missing`, `persistence` (frozen for 6.x) | +| `FetcherResult` | `deleted`, `error`, `notModified`, `success` | + +```swift +switch onEnum(of: result) { +case .data(let data): /* data.value, .origin, .age, .isStale, .refreshing */ +case .error(let failure): switch onEnum(of: failure.error) { /* six StoreError cases */ } +case .loading: break +case .revalidated(let frame): /* frame.age */ +} +``` + +## Suspend ops, `close()`, `stream` + +| Op | SKIE | ObjC | +| --- | --- | --- | +| `get(key:freshness:)` | `async throws -> Any` | `get(key:freshness:completionHandler:)` | +| `invalidate(key:)` / `invalidateNamespace(namespace:)` / `invalidateAll()` | `async throws` | matching `…(completionHandler:)` | +| `clear(key:)` / `clearNamespace(namespace:)` / `clearAll()` | `async throws` | matching `…(completionHandler:)` | +| `close()` | synchronous — no async wrapper | `close()` — no completion handler | +| `stream(key:freshness:)` | returns `Kotlinx_coroutines_coreFlow` | same; not a completion-handler op | + +`stream` itself is **not** `AsyncSequence`. Wrap the returned `Kotlinx_coroutines_coreFlow` in `SkieSwiftFlow` (`SkieSwiftFlowProtocol` : `AsyncSequence`; public convenience init takes `SkieKotlinFlow`). Iteration is task-scoped; cancellation is wired through the SKIE iterator. Flow elements are erased (`Any` / `AnyObject`) — do not invent a typed `StoreResult` generic if the dump does not give you one. + +```swift +let flow = store.stream(key: key, freshness: freshness) // Kotlinx_coroutines_coreFlow +for await result in SkieSwiftFlow(SkieKotlinFlow(flow)) { + switch onEnum(of: result) { /* four StoreResult cases */ } +} +``` + +## `int64_t` trap — `Duration` raw vs epoch millis + +ObjC export flattens both Kotlin `Duration` and `Long` to `int64_t`. They are not the same unit. Do not pass a bare integer like `5000` as if it were seconds or millis for a `Duration` field. + +| Field | `int64_t` means | +| --- | --- | +| `Freshness.MaxAge.notOlderThan` | Kotlin `Duration` **raw representation** (not millis, not seconds) | +| `StoreResult.Data.age` | Kotlin `Duration` **raw representation** | +| `StoreResult.Revalidated.age` | Kotlin `Duration` **raw representation** | +| `StoreMeta.writtenAtEpochMillis` | Unix epoch **milliseconds** | +| `Bookkeeper.recordFailure` `atEpochMillis` | Unix epoch **milliseconds** | +| `WallClock.nowEpochMillis` | Unix epoch **milliseconds** | + +Construct `Duration` in Kotlin. Read epoch-millis fields as Unix milliseconds. + +## Exception boundary + +| Lane | Cancellation | Other uncaught Kotlin exceptions | +| --- | --- | --- | +| ObjC | `CancellationException` → `NSError` | **fatal**. `StoreException` from `get` is fatal, not an `NSError`. | +| SKIE | → `CancellationError` | unexpected errors during flow `hasNext()` → `fatalError` | + +## One failure channel + +`stream` emits and never throws retrieval failures. `get` throws and never emits. diff --git a/plugins/store/skills/migrating-to-store6/SKILL.md b/plugins/store/skills/migrating-to-store6/SKILL.md new file mode 100644 index 000000000..3d4c76a36 --- /dev/null +++ b/plugins/store/skills/migrating-to-store6/SKILL.md @@ -0,0 +1,63 @@ +--- +name: migrating-to-store6 +description: Use when migrating Kotlin code from Store 4 or Store 5 (org.mobilenativefoundation.store) to Store 6, when Store 5 spellings like StoreBuilder.from, Fetcher.of, SourceOfTruth.of, StoreReadRequest, StoreReadResponse, MutableStore, Validator, or store.fresh appear in code that should target Store 6, or when writing Store 6 code and unsure whether an API exists. +--- + +# Migrating to Store 6 + +## Overview + +Store 6 is a redesigned API, not a renamed Store 5. **The Store 5 builder, request, and response vocabulary does not exist in Store 6.** A package-rename port cannot compile. Migrate by translating with the tables in [references/store5-to-store6.md](references/store5-to-store6.md), one complete screen at a time. + +**Never invent a Store 6 API.** If a spelling is not in this skill, its references, or the Store 6 sources you can verify against, assume it does not exist and say so instead of guessing. "Store 6 is mostly a rename" is false regardless of who says it. + +## Ground truth + +- **Packages:** `org.mobilenativefoundation.store6.core` (core) and `org.mobilenativefoundation.store6.mutations` (experimental writes). Types are not directly under `org.mobilenativefoundation.store6`. +- **Publishing:** nothing is published before `6.0.0-alpha01`. Do not write dependency coordinates from memory. Verify them against the release you target. Store 5 coordinates stay published for the whole 6.x major, so both can coexist in one build. +- **Builder:** `store { fetcher { key -> value } }`. A fetcher block is the one required input, and building without one throws `IllegalArgumentException`. `StoreBuilder.from`, `Fetcher.of`, `SourceOfTruth.of`, `Validator.by`, `.validator(...)`, `.scope(...)`, and `.cachePolicy(...)` do not exist. +- **Keys:** every key implements `StoreKey` (`namespace: StoreNamespace`, `canonicalId(): String`). A key type that does not implement `StoreKey`, such as a plain `String`, does not satisfy the `K : StoreKey` bound. +- **Reads:** freshness is per call: `stream(key, freshness)` and suspending `get(key, freshness)`, default `Freshness.CachedOrFetch`. `StoreReadRequest` does not exist. Exactly five policies: `CachedOrFetch`, `MaxAge(notOlderThan)`, `MustBeFresh`, `StaleIfError`, `LocalOnly`. +- **Results:** `StoreResult` has exactly four kinds: `Loading`, `Data(value, origin, age, isStale, refreshing)`, `Revalidated(age)`, `Error(error, servedStale)`. `StoreReadResponse`, `Initial`, `NoNewData`, `requireData()`, and `dataOrNull()` do not exist. One failure channel: `stream` emits errors and never throws retrieval failures. `get` returns a value or throws `StoreException`. +- **Persistence:** the seam `SourceOfTruth` is installed via `persistence(...)`. Both are `@ExperimentalStoreApi`, and implementing the interface additionally requires opt-in to `DelicateStoreApi`. Prefer the `store6-room` or `store6-sqldelight` adapters. Validate custom implementations with the `store6-testing` contract kit. +- **Maintenance:** `invalidate`/`invalidateNamespace`/`invalidateAll` mark data stale and keep it. `clear`/`clearNamespace`/`clearAll` destructively remove it. Decision test: clear when the value is wrong to show, invalidate when it is merely old. Lifecycle is explicit: release a store with `close()`. +- **Engine behavior you no longer build:** per-key single-flight fetch deduplication, stale-while-revalidate, durable invalidation across restarts, and a quiescent idle-key bound (`maxIdleKeys`, default 128). Store performs no retries and no fallback chain. That policy belongs inside your fetcher. + +## Workflow: one screen at a time + +1. Keep the Store 5 dependency and its working screens in place. Add Store 6 alongside. +2. Design the `StoreKey`. The namespace is what you invalidate together, and `canonicalId()` includes everything that can change the returned value. +3. Port the fetcher. Move retry, backoff, and fallback policy inside it. +4. Wire persistence through an adapter or the seam. +5. Translate read sites and result handling with [references/store5-to-store6.md](references/store5-to-store6.md). +6. Translate maintenance calls (invalidate vs clear) and give the store an owner that calls `close()`. +7. Delete that screen's Store 5 store, then repeat. Until a Store 5 interop artifact ships, the two versions do not share cache state. Move whole screens, never one screen's fetch/persist/read sites split across versions. + +## Common mistakes + +| Mistake | Reality | +| --- | --- | +| Port by renaming imports ("it's mostly a rename") | The renamed spellings do not exist, so the port cannot compile. Translate with the tables instead. | +| `store.fresh(id)` or `impl.extensions` imports | `get(key, Freshness.MustBeFresh)` | +| Treating `StoreReadRequest.cached(key, refresh = true)` as one call | No single equivalent: collect the default `stream(key)` and call `invalidate(key)` when the caller asks to refresh | +| `when` over only Loading/Data/Error | `Revalidated` is a fourth kind: the value was confirmed fresh, there is nothing new to render | +| Passing a `String` key | Implement `StoreKey` | +| Carrying `Validator` over | Per-call `Freshness` (usually `MaxAge`) plus durable invalidation | +| Assuming no opt-ins | Persistence, seams, and all of mutations require `@OptIn(ExperimentalStoreApi::class)` | +| Translating `NoNewData` | No analog: a Store 6 fetcher has no empty-flow outcome | +| Adding TTL cache config or retry wrappers around the store | No cache policy knob exists (`maxIdleKeys` bounds idle engines, not data lifetime). Retries live inside the fetcher | + +## Red flags: stop and open the tables + +About to type `StoreBuilder`, `Fetcher.of`, `SourceOfTruth.of`, `StoreReadRequest`, `StoreReadResponse`, `Validator`, `store.fresh(`, or a bare-`String` key against Store 6? Those are Store 5 spellings. Open [references/store5-to-store6.md](references/store5-to-store6.md) before writing the line. + +## References + +- [references/store5-to-store6.md](references/store5-to-store6.md): full translation tables and a worked before/after port +- [references/component-map.md](references/component-map.md): all eight Store 5 components, row by row +- [references/from-store4.md](references/from-store4.md): the Store 4 starting point and which rows to skip +- [references/mutations.md](references/mutations.md): `MutableStore`/`Updater` to the journalled mutation path (experimental) + +--- + +Last verified against Store `main` @ `c67a94ed` (pre-`6.0.0-alpha01`). Re-verify spellings against the release you target. diff --git a/plugins/store/skills/migrating-to-store6/references/component-map.md b/plugins/store/skills/migrating-to-store6/references/component-map.md new file mode 100644 index 000000000..b2a29c2d8 --- /dev/null +++ b/plugins/store/skills/migrating-to-store6/references/component-map.md @@ -0,0 +1,51 @@ +# Store 5 components → Store 6, row by row + +Store 5 documents eight components: Store, Fetcher, SourceOfTruth, Converter, Validator, MutableStore, Updater, and Bookkeeper. Seven rows cover them because `MutableStore` and `Updater` move together. + +| Store 5 component | Store 6 replacement | +| --- | --- | +| `Store` | `Store` with `stream(key, freshness)`, suspending `get(key, freshness)`, namespace-aware `invalidate*`/`clear*`, and explicit `close()` | +| `Fetcher` | `fetcher { }`, `fetcherOfResult { }`, or the experimental seam `Fetcher` (receives conditional-request ETags). Last registration wins across all three. | +| `SourceOfTruth` | The seam `SourceOfTruth` installed via `persistence(...)`, or a `store6-room`/`store6-sqldelight` adapter | +| `Converter` | **No direct analog.** Mapping lives in fetcher and persistence callbacks. | +| `Validator` | Native per-call `Freshness`, durable invalidation, and the expert `FreshnessValidator` read-planning seam | +| `MutableStore` + `Updater` | `mutationStore` plus typed `mutate`. **Updater has no direct analog:** its transport job moves to an app-owned `MutationServer` invoked by foreground `drain`. See [mutations.md](mutations.md). | +| Failed-sync `Bookkeeper` | **No direct analog.** Durable mutation-journal records and inspection replace the job. | + +## Store → Store + +Store 5 centers reads on `stream(StoreReadRequest)`. Store 6 uses `stream(key, freshness)` and adds suspending `get(key, freshness)`. One failure channel: `stream` emits `StoreResult.Error` and never throws retrieval failures (a `MustBeFresh` initial-cycle failure emits one error and completes the flow). `get` returns a value or throws `StoreException` and never emits a wrapper. + +`clear(key)`/`clearAll()` become two families. `invalidate*` marks stale and preserves values. `clear*` destructively removes values and their per-key freshness records. Namespace and global stale watermarks are conservative and are not reset by clear operations. + +## Fetcher → fetcher, fetcherOfResult, or the seam + +Store 5's `FetcherResult.Data` and three error shapes become `FetcherResult.Success(value, etag)` and `FetcherResult.Error(cause)`. Store 6 adds `NotModified(etag)`, which produces one `StoreResult.Revalidated`, and `Deleted`, which clears the resident value without an automatic refetch. + +No engine-level fallback chain exists. Store performs zero retries. Compose retry, backoff, or fallback endpoints inside your fetcher. + +## SourceOfTruth → the persistence seam + +Store 5's `SourceOfTruth` had separate local and output types, with a `Converter` bridging the fetcher's network type. The Store 6 seam is `SourceOfTruth` with one value type and a nullable-row reader. + +The contract: `reader(key)` immediately first-emits the current row or `null`, stays live, and publishes changes made through that instance. Mutations provide read-your-writes on normal return and are exception-atomic, including cancellation. `deleteNamespace` is new. Validate implementations with the `store6-testing` contract kit. + +## Converter → callbacks + +No converter seam exists. A store is typed on one value `V`: map network payloads to `V` inside the fetcher, and map `V` to and from database rows inside the persistence adapter's callbacks. The conversion still exists. It is owned at the boundary where the representation changes. + +## Validator → native freshness + +Store 5's `Validator.isValid(item)` asked one per-item question. Store 6 plans each read from resident availability, freshness metadata, durable staleness, and one of five per-call policies (`CachedOrFetch`, `MaxAge`, `MustBeFresh`, `StaleIfError`, `LocalOnly`). Every `StoreResult.Data` reports `isStale`, and `invalidate*` records staleness directly. + +The experimental `FreshnessValidator` seam is not a per-item validity hook: its pure `plan(context)` returns a fetch plan (`Skip`, `Fetch`, or `Conditional`) for one coherent read snapshot. Most applications should use the native policies. + +## Bookkeeper → the journal, with a name collision + +Store 5's `Bookkeeper` recorded failed-sync timestamps so later reads could detect unsynced local changes. No Store 6 component has that job. The mutation journal replaces the system with durable intents, attempt generations, acknowledgement progress, normalized failures, and retirement, inspected through `pending(key)`, `pendingWrites()`, and `deadLetters()`. + +Name collision: Store 6 core also has a type named `Bookkeeper`, but it records freshness metadata, per-key stale marks, and namespace/global watermarks. It does not track failed write synchronization. + +## Not components in Store 6 + +Memory-cache configuration: `maxIdleKeys` (default 128) bounds quiescent per-key engine residency. Eviction discards derived engine state, never durable rows, metadata, stale marks, or watermarks. Builder `scope(...)`: no counterpart. Stores own their work and release it through `close()`. diff --git a/plugins/store/skills/migrating-to-store6/references/from-store4.md b/plugins/store/skills/migrating-to-store6/references/from-store4.md new file mode 100644 index 000000000..4d610aa53 --- /dev/null +++ b/plugins/store/skills/migrating-to-store6/references/from-store4.md @@ -0,0 +1,25 @@ +# Starting from Store 4 + +Store 4 used `com.dropbox` packages. Two release statements bound this path: Store 5's early multiplatform release states that concepts and usage were unchanged from Store 4, and the Store 5 stable release describes its additions over Store 4 as having no breaking changes. The [Store 5 translation tables](store5-to-store6.md) therefore apply to a Store 4 codebase directly. Skip the Store 5-only rows instead of migrating to Store 5 first. + +## Rows that do not apply to you + +Store 5 added `MutableStore`, `Validator`, fallback mechanisms, write-conflict resolution, and `NoNewData` after Store 4. A Store 4 application has none of these to translate. Per-call freshness policies and the journalled mutation path are new capabilities to evaluate, not behaviors to port. + +## The rows that remain + +| Store 4-era responsibility | Store 6 path | +| --- | --- | +| Fetcher | `fetcher { }`, `fetcherOfResult { }`, or the experimental seam `Fetcher` | +| Persister / SourceOfTruth | The persistence seam or a `store6-room`/`store6-sqldelight` adapter | +| Converter | No direct analog. Conversion lives in fetcher and persistence callbacks | +| Read sites | `get(key, freshness)` for a point read, `stream(key, freshness)` for an ongoing flow | + +Two Store 4-era read patterns and their translations: + +- `store.fresh(key)` (a Store 4 extension retained through Store 5) → `get(key, Freshness.MustBeFresh)` when the caller needs one fresh value. +- `store.stream(StoreRequest.cached(key, refresh = true))` (`StoreRequest` is Store 4's request type) → an ongoing `stream(key)` plus deliberate `invalidate(key)` when the caller requests a refresh. Not a one-call mechanical rename. + +## Coexistence + +Store 6 uses group `org.mobilenativefoundation.store` and packages under `org.mobilenativefoundation.store6.*`. Nothing is published before `6.0.0-alpha01`. The formal side-by-side coexistence promise begins with Store 5. No Store 4 artifact availability or interop is guaranteed. If the Store 4 dependency still resolves, keep it in place and move one complete screen at a time. diff --git a/plugins/store/skills/migrating-to-store6/references/mutations.md b/plugins/store/skills/migrating-to-store6/references/mutations.md new file mode 100644 index 000000000..44a0d69ff --- /dev/null +++ b/plugins/store/skills/migrating-to-store6/references/mutations.md @@ -0,0 +1,46 @@ +# MutableStore and Updater → the journalled mutation path + +`store6-mutations` is a separate experimental artifact: every public symbol carries `@ExperimentalStoreApi`, so shapes may change or be removed in any release. Confirm the consuming team has accepted that before porting writes. Reads can migrate to core first. + +`mutationStore(...)` returns a `MutationStore`, which implements `Store`, so read, freshness, invalidation, clear, and close behavior follows the core read contract. + +## The write vocabulary + +Store 5's `MutableStore.write(...)` and `Updater.post(...)` move into one journalled path: + +1. Register named, typed write shapes once in a `MutatorRegistry` (`mutator`, `update`, `create`, `delete`, or `upsert`). No call-site closure becomes a durable intent. `update` declines when the confirmed base is absent, `delete` always applies absence, `upsert` cannot decline. +2. Enqueue with `mutate(key, ref, args)`. It returns an opaque mutation id and does not push. +3. Push one foreground pass with `drain(key)` or `drain()`. A drain performs no retry or backoff and never fetches. +4. Implement the app-owned `MutationServer` transport contract: exactly two methods, `push(request): MutationAck` and `retire(request): MutationRetirementAck`. +5. Inspect durable truth with `pending(key)`, `pendingWrites()`, and `deadLetters()`. + +Restart-safe key recovery is compile-time required. The registry, server, key resolver, and value codec/version are factory inputs: + +```kotlin +@OptIn(ExperimentalStoreApi::class) // the whole module is experimental +val users = mutationStore( + registry = registry, + server = server, + keyResolver = MutationKeyResolver { identity -> UserKey(identity.canonicalId) }, + valueCodecVersion = 1, + valueCodec = userJsonCodec, +) { + fetcher { key -> api.load(key) } +} + +users.mutate(key, renameRef, Rename("new name")) // journalled — the only write path +users.drain(key) // push pending intents and adopt each ack +``` + +## Contract points that differ from Store 5 + +- Optimistic values appear only on `stream`, with `origin == Origin.OVERLAY`, `age = Duration.ZERO`, and `isStale = false`. `get` remains a point read of committed truth. Drive pending-write UI from the origin, never from `isStale`. +- `runtime()` on a mutation store returns `null`: consumer writes cannot bypass the journal through the raw engine write handle. +- Conflict handling is an optional `conflicts { precondition(...); merge(...) }` block. Without a registered merge, server-wins is the non-removable terminal. +- The per-key write queue becomes a durable FIFO ordered by client sequence. +- The default journal is in-memory. Use the SQLDelight journal adapter (or another conforming durable implementation) when queued work must survive process restart. +- The server contract requires idempotency: a repeated idempotency key must be treated as the same request. If remote acceptance lands before the local acknowledgement-receipt commits, the durable phase stays `INFLIGHT` and a later drain may replay the same immutable generation. Once `ACKED` is durable, recovery may repeat local adoption, effects, and retirement, but never calls `MutationServer.push` again for that generation. + +## Store 5 Bookkeeper users + +The failed-sync `Bookkeeper` job is replaced by the journal's durable records and the inspection surfaces above, not by a component with the same name. See the name-collision note in [component-map.md](component-map.md). diff --git a/plugins/store/skills/migrating-to-store6/references/store5-to-store6.md b/plugins/store/skills/migrating-to-store6/references/store5-to-store6.md new file mode 100644 index 000000000..d37ca9d81 --- /dev/null +++ b/plugins/store/skills/migrating-to-store6/references/store5-to-store6.md @@ -0,0 +1,216 @@ +# Store 5 to Store 6 translation tables + +Every spelling below is verified against Store `main` @ `c67a94ed`. Rows marked "nearest translation" are intent translations, not behavioral equivalents. The differences are stated in the row. + +## Imports + +| Store 5 | Store 6 | +| --- | --- | +| `org.mobilenativefoundation.store.store5.*` | `org.mobilenativefoundation.store6.core.*` (core), `org.mobilenativefoundation.store6.core.seam.*` (expert seams), `org.mobilenativefoundation.store6.mutations.*` (experimental writes) | +| `org.mobilenativefoundation.store.store5.impl.extensions.fresh` | Does not exist. Use `get(key, Freshness.MustBeFresh)`. | + +## Builder + +Store 5: + +```kotlin +val store = StoreBuilder + .from(fetcher = Fetcher.of { key -> api.fetch(key) }, sourceOfTruth = sot) + .build() +``` + +Store 6: + +```kotlin +val store = store { + fetcher { key -> api.fetch(key.id) } // the one required input +} +``` + +| Store 5 builder setting | Store 6 | +| --- | --- | +| `Fetcher.of { }` | `fetcher { key -> value }` (success-or-throw sugar) | +| `Fetcher.ofResult { }` | `fetcherOfResult { key -> FetcherResult }` with the full result vocabulary: `Success(value, etag)`, `Error(cause)`, `NotModified(etag)` (emits `Revalidated`), `Deleted` (clears without refetch) | +| Fallback fetcher chains | No engine support. Store performs zero retries and no fallback chain. Compose them inside your fetcher. | +| `sourceOfTruth = SourceOfTruth.of(reader, writer, delete, deleteAll)` | `persistence(sot)` where `sot` implements the seam interface `SourceOfTruth` (`reader(key): Flow`, `write(key, value)`, `delete(key)`, `deleteNamespace(namespace)`, `deleteAll()`). `persistence` and the interface are `@ExperimentalStoreApi`, and implementing the interface additionally requires `DelicateStoreApi` opt-in. Prefer the `store6-room`/`store6-sqldelight` adapters. | +| `validator(Validator.by { ... })` | No per-item validity hook. Use per-call `Freshness` (usually `MaxAge`) plus `invalidate*`. The experimental `FreshnessValidator` seam is a read planner, not a validity check. | +| `scope(...)` | No counterpart. The store owns its lifecycle. Release it with `close()`. After close, operations fail with `IllegalStateException("Store is closed.")`. | +| `cachePolicy(...)` / `disableCache()` | No TTL cache policy. `maxIdleKeys(count)` (default 128) bounds quiescent per-key engine residency, and `0` destroys each engine at quiescence. Eviction discards derived state only. Durable rows, stale marks, and watermarks survive. | + +Keys change shape. Store 5 accepts any non-null key. A Store 6 key implements `StoreKey` and supplies `namespace` and `canonicalId()`, which together form the durable identity. + +```kotlin +class UserKey(val id: String) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("users") + override fun canonicalId(): String = id +} +``` + +## Read requests → Freshness + +Store 5 puts cache and fetch choices in `StoreReadRequest`. Store 6 puts a `Freshness` policy on each call. Calls with different policies still share one in-flight fetch per key. + +| Store 5 request | Store 6 translation | +| --- | --- | +| `StoreReadRequest.cached(key, refresh = false)` | Nearest translation: `stream(key)` or `get(key)` with default `Freshness.CachedOrFetch`. Still fetches when nothing is local, but may also serve-and-background-revalidate invalidated or metadata-less local residence. | +| `StoreReadRequest.fresh(key)` | Nearest translation: `Freshness.MustBeFresh`, which withholds residence and blocks for a fresh fetch. Store 5 could emit `NoNewData` on an empty fetcher flow. Store 6 has no empty-flow outcome: a failed `MustBeFresh` read emits one `StoreResult.Error` and completes the flow (`stream`) or throws `StoreException` (`get`). | +| `StoreReadRequest.localOnly(key)` | `Freshness.LocalOnly`: never invokes the fetcher, probes persistence once on a memory miss, reports `StoreError.Missing` when nothing is local. | +| `StoreReadRequest.fresh(key, fallBackToSourceOfTruth = true)` | Nearest policy: `Freshness.StaleIfError`, which prefers fresh and falls back to the stale local value when the fetch fails. Not an exact equivalence. | +| `StoreReadRequest.cached(key, refresh = true)` | No single policy. For caller-initiated refresh, call `invalidate(key)` and keep collecting the default stale-while-revalidate stream. | +| `StoreReadRequest.skipMemory(key, refresh)` | No equivalent. Store 6 does not expose per-call skipping of storage layers. | + +## Read responses → StoreResult + +| Store 5 | Store 6 | +| --- | --- | +| `Initial` / `Loading` | `Loading`: emitted when demand exists and no value is servable. No separate `Initial` kind. | +| `Data(value, origin)` | `Data(value, origin, age, isStale, refreshing)` | +| `NoNewData` | No analog (it meant a Store 5 fetcher flow completed without data). | +| `Error.Exception` / `Error.Message` / `Error.Custom` | `Error(error: StoreError, servedStale)`. `StoreError` has six variants: `Fetch`, `Persistence`, `Conversion`, `FreshnessUnsatisfiable`, `Conflict`, `Missing`. Each variant carries a `message`, the sealed base does not, so match exhaustively. `servedStale` is true when a stale resident was served and its refresh then failed under a stale-tolerant policy. | +| No analog | `Revalidated(age)`: the not-modified result of a conditional fetch. Clears staleness without emitting redundant `Data`. Handle it in every exhaustive `when`. | + +Origins: + +| Store 5 origin | Store 6 `Origin` | +| --- | --- | +| `Cache` | `MEMORY` | +| `SourceOfTruth` | `SOT` | +| `Fetcher(name)` | `FETCHER` | +| `Initial` | No analog. Store 6 has no `Initial` response kind, and `Loading` carries no origin. | +| No analog | `OVERLAY`: an optimistic projection above committed data, visible on `stream` only. The mutation engine installs one, and core's experimental `overlay(...)` builder seam can too. | + +Helpers `requireData()`, `dataOrNull()`, and `throwIfError()` do not carry over. The two doors replace them. `get` returns a value or throws `StoreException`. `stream` emits results and never throws retrieval failures: a `Freshness.MustBeFresh` initial-cycle failure emits one error and completes the flow, and every other failure leaves the flow live. + +`StoreException` exposes the structured failure as `error: StoreError` and the underlying failure through the standard exception `cause` (nullable). Store 5 callers that caught the fetcher's own exception type (for example `IOException`) from `fresh` must catch `StoreException` and inspect `error` or `cause` instead. + +## Maintenance + +| Store 5 | Store 6 | +| --- | --- | +| `clear(key)` used to force a refresh | `invalidate(key)`: marks stale, keeps the value visible, triggers exactly one refresh for live streams. Also `invalidateNamespace(namespace)`, `invalidateAll()`. Stale marks are durable across restarts and do not require a resident value: a key is durably stale when its mark or a namespace/global watermark is newer than its last successful fetch, so invalidating a not-yet-fetched key is valid and applies to future reads. | +| `clear(key)` / `clearAll()` used to remove data | `clear(key)`, `clearNamespace(namespace)`, `clearAll()`: destructively remove values and their per-key freshness records. A post-clear stream never replays pre-clear data. | +| (no counterpart) | `close()`: releases the store. Subsequent operations throw `IllegalStateException`. | + +All `invalidate*` and `clear*` operations are suspending and can throw `StoreException` when persisting the mark or performing durable deletion fails. `close()` is a plain function. + +Decision test: if the value is wrong to show, clear it. If it is merely imperfect or old, invalidate it. + +## Worked port + +The Store 5 file below is a common screen shape: builder with source of truth, a 5-minute `Validator`, `cached(refresh = true)` for the screen subscription, `fresh` for pull-to-refresh, `clearAll` on sign-out. + +```kotlin +// Store 5 (before) +private val store = StoreBuilder + .from( + fetcher = Fetcher.of { id: String -> api.fetchUser(id) }, + sourceOfTruth = SourceOfTruth.of( + reader = { id -> dao.observeUser(id) }, + writer = { _, user -> dao.upsert(user) }, + delete = { id -> dao.delete(id) }, + deleteAll = { dao.deleteAll() }, + ), + ) + .validator(Validator.by { user -> nowMillis() - user.updatedAtMillis < 5.minutes.inWholeMilliseconds }) + .build() + +fun observeUser(id: String): Flow = + store.stream(StoreReadRequest.cached(key = id, refresh = true)).map { response -> + when (response) { + is StoreReadResponse.Initial, is StoreReadResponse.Loading -> UserUiState.Loading + is StoreReadResponse.Data -> UserUiState.Loaded(response.value, response.origin is StoreReadResponseOrigin.Cache) + is StoreReadResponse.NoNewData -> UserUiState.Loading + is StoreReadResponse.Error.Exception -> UserUiState.Failed(response.error.message ?: "Unknown error") + is StoreReadResponse.Error.Message -> UserUiState.Failed(response.message) + is StoreReadResponse.Error.Custom<*> -> UserUiState.Failed("Unknown error") + } + } + +suspend fun refreshUser(id: String): User = store.fresh(id) +suspend fun onSignOut() = store.clearAll() +``` + +The Store 6 port. It translates intent and is not row-for-row behaviorally identical. Differences are noted inline: + +```kotlin +// Store 6 (after) +import kotlin.time.Duration.Companion.minutes +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import org.mobilenativefoundation.store6.core.store + +class UserKey(val id: String) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("users") + override fun canonicalId(): String = id +} + +// Implementing the seam requires both opt-ins. Validate custom implementations with the +// store6-testing contract kit; prefer the store6-room/store6-sqldelight adapters when the app +// already has a database. +@OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) +private class UserDaoSourceOfTruth(private val dao: UserDao) : SourceOfTruth { + override fun reader(key: UserKey): Flow = dao.observeUser(key.id) + override suspend fun write(key: UserKey, value: User) = dao.upsert(value) + override suspend fun delete(key: UserKey) = dao.delete(key.id) + // This store has a single namespace, so both scopes clear the same table. + override suspend fun deleteNamespace(namespace: StoreNamespace) = dao.deleteAll() + override suspend fun deleteAll() = dao.deleteAll() +} + +class UserRepository(api: UserApi, dao: UserDao) { + @OptIn(ExperimentalStoreApi::class) + private val store = store { + fetcher { key -> api.fetchUser(key.id) } // retries and fallbacks belong inside this block + persistence(UserDaoSourceOfTruth(dao)) + } + + // The Store 5 Validator bounded staleness at 5 minutes; MaxAge puts that bound on the call. + // Difference: age is measured from commit time, not the value's own timestamp field, and an + // over-age resident is withheld until the fetch succeeds rather than emitted alongside it. + fun observeUser(id: String): Flow = + store.stream(UserKey(id), Freshness.MaxAge(5.minutes)) + .map { result -> + when (result) { + is StoreResult.Loading -> UserUiState.Loading + is StoreResult.Data -> UserUiState.Loaded( + user = result.value, + fromCache = result.origin == Origin.MEMORY, + ) + // The conditional fetch confirmed the current value is fresh; nothing new to render. + is StoreResult.Revalidated -> null + is StoreResult.Error -> UserUiState.Failed(result.error.describe()) + } + } + .filterNotNull() + + // Store 5 store.fresh(id): block for a network round trip or fail. + suspend fun refreshUser(id: String): User = store.get(UserKey(id), Freshness.MustBeFresh) + + // Sign-out data is wrong to show afterward, so clear rather than invalidate. + suspend fun onSignOut() = store.clearAll() + + // No builder scope(...): the owner releases the store explicitly. + fun close() = store.close() +} + +private fun StoreError.describe(): String = when (this) { + is StoreError.Fetch -> message + is StoreError.Persistence -> message + is StoreError.Conversion -> message + is StoreError.FreshnessUnsatisfiable -> message + is StoreError.Conflict -> message + is StoreError.Missing -> message +} +``` + +If the screen must refetch on every subscription regardless of age (exact `cached(refresh = true)` behavior), keep the default `stream(key)` and have the refresh action call `invalidate(key)`. There is no single-policy equivalent. diff --git a/renovate.json b/renovate.json index 5db72dd6a..399b7c211 100644 --- a/renovate.json +++ b/renovate.json @@ -2,5 +2,19 @@ "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ "config:recommended" + ], + "packageRules": [ + { + "description": "Kotlin-locked (klib ABI): move only with baseKotlin, as one manual coordinated bump.", + "matchPackagePrefixes": [ + "app.cash.sqldelight", + "org.jetbrains.compose", + "org.jetbrains.androidx.lifecycle", + "com.google.devtools.ksp", + "androidx.room3", + "androidx.sqlite" + ], + "enabled": false + } ] } diff --git a/rx2/api/rx2.api b/rx2/api/rx2.api deleted file mode 100644 index 8c2b6797c..000000000 --- a/rx2/api/rx2.api +++ /dev/null @@ -1,26 +0,0 @@ -public final class org/mobilenativefoundation/store/rx2/RxFetcherKt { - public static final fun ofFlowable (Lorg/mobilenativefoundation/store/store5/Fetcher$Companion;Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store/store5/Fetcher; - public static final fun ofResultFlowable (Lorg/mobilenativefoundation/store/store5/Fetcher$Companion;Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store/store5/Fetcher; - public static final fun ofResultSingle (Lorg/mobilenativefoundation/store/store5/Fetcher$Companion;Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store/store5/Fetcher; - public static final fun ofSingle (Lorg/mobilenativefoundation/store/store5/Fetcher$Companion;Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store/store5/Fetcher; -} - -public final class org/mobilenativefoundation/store/rx2/RxSourceOfTruthKt { - public static final fun ofFlowable (Lorg/mobilenativefoundation/store/store5/SourceOfTruth$Companion;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;)Lorg/mobilenativefoundation/store/store5/SourceOfTruth; - public static synthetic fun ofFlowable$default (Lorg/mobilenativefoundation/store/store5/SourceOfTruth$Companion;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/SourceOfTruth; - public static final fun ofMaybe (Lorg/mobilenativefoundation/store/store5/SourceOfTruth$Companion;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;)Lorg/mobilenativefoundation/store/store5/SourceOfTruth; - public static synthetic fun ofMaybe$default (Lorg/mobilenativefoundation/store/store5/SourceOfTruth$Companion;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/SourceOfTruth; -} - -public final class org/mobilenativefoundation/store/rx2/RxStoreBuilderKt { - public static final fun withScheduler (Lorg/mobilenativefoundation/store/store5/StoreBuilder;Lio/reactivex/Scheduler;)Lorg/mobilenativefoundation/store/store5/StoreBuilder; -} - -public final class org/mobilenativefoundation/store/rx2/RxStoreKt { - public static final fun freshSingle (Lorg/mobilenativefoundation/store/store5/Store;Ljava/lang/Object;)Lio/reactivex/Single; - public static final fun getSingle (Lorg/mobilenativefoundation/store/store5/Store;Ljava/lang/Object;)Lio/reactivex/Single; - public static final fun observe (Lorg/mobilenativefoundation/store/store5/Store;Lorg/mobilenativefoundation/store/store5/StoreReadRequest;)Lio/reactivex/Flowable; - public static final fun observeClear (Lorg/mobilenativefoundation/store/store5/Store;Ljava/lang/Object;)Lio/reactivex/Completable; - public static final fun observeClearAll (Lorg/mobilenativefoundation/store/store5/Store;)Lio/reactivex/Completable; -} - diff --git a/rx2/build.gradle.kts b/rx2/build.gradle.kts deleted file mode 100644 index bf887b115..000000000 --- a/rx2/build.gradle.kts +++ /dev/null @@ -1,23 +0,0 @@ -@file:Suppress("UnstableApiUsage") - -plugins { - id("org.mobilenativefoundation.store.android") -} - -dependencies { - implementation(libs.kotlinx.coroutines.rx2) - implementation(libs.kotlinx.coroutines.core) - implementation(libs.kotlinx.coroutines.android) - implementation(libs.rxjava) - implementation(projects.store) - - testImplementation(kotlin("test")) - testImplementation(libs.junit) - testImplementation(libs.google.truth) - testImplementation(libs.androidx.test.core) - testImplementation(libs.kotlinx.coroutines.test) -} - -android { - namespace = "org.mobilenativefoundation.store.rx2" -} diff --git a/rx2/gradle.properties b/rx2/gradle.properties deleted file mode 100644 index dc283f53f..000000000 --- a/rx2/gradle.properties +++ /dev/null @@ -1,3 +0,0 @@ -POM_NAME=org.mobilenativefoundation.store -POM_ARTIFACT_ID=rx2 -POM_PACKAGING=jar \ No newline at end of file diff --git a/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxFetcher.kt b/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxFetcher.kt deleted file mode 100644 index 57332ee8e..000000000 --- a/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxFetcher.kt +++ /dev/null @@ -1,68 +0,0 @@ -package org.mobilenativefoundation.store.rx2 - -import io.reactivex.Flowable -import io.reactivex.Single -import kotlinx.coroutines.reactive.asFlow -import org.mobilenativefoundation.store.store5.Fetcher -import org.mobilenativefoundation.store.store5.FetcherResult -import org.mobilenativefoundation.store.store5.Store - -/** - * Creates a new [Fetcher] from a [flowableFactory]. - * - * [Store] does not catch exception thrown in [flowableFactory] or in the returned [Flowable]. These - * exception will be propagated to the caller. - * - * Use when creating a [Store] that fetches objects in a multiple responses per request - * network protocol (e.g Web Sockets). - * - * @param flowableFactory a factory for a [Flowable] source of network records. - */ -fun Fetcher.Companion.ofResultFlowable( - flowableFactory: (key: Key) -> Flowable>, -): Fetcher = ofResultFlow { key: Key -> flowableFactory(key).asFlow() } - -/** - * "Creates" a [Fetcher] from a [singleFactory]. - * - * [Store] does not catch exception thrown in [singleFactory] or in the returned [Single]. These - * exception will be propagated to the caller. - * - * Use when creating a [Store] that fetches objects in a single response per request network - * protocol (e.g Http). - * - * @param singleFactory a factory for a [Single] source of network records. - */ -fun Fetcher.Companion.ofResultSingle( - singleFactory: (key: Key) -> Single>, -): Fetcher = ofResultFlowable { key: Key -> singleFactory(key).toFlowable() } - -/** - * "Creates" a [Fetcher] from a [flowableFactory] and translate the results to a [FetcherResult]. - * - * Emitted values will be wrapped in [FetcherResult.Data]. if an exception disrupts the stream then - * it will be wrapped in [FetcherResult.Error]. Exceptions thrown in [flowableFactory] itself are - * not caught and will be returned to the caller. - * - * Use when creating a [Store] that fetches objects in a multiple responses per request - * network protocol (e.g Web Sockets). - * - * @param flowFactory a factory for a [Flowable] source of network records. - */ -fun Fetcher.Companion.ofFlowable(flowableFactory: (key: Key) -> Flowable): Fetcher = - ofFlow { key: Key -> flowableFactory(key).asFlow() } - -/** - * Creates a new [Fetcher] from a [singleFactory] and translate the results to a [FetcherResult]. - * - * The emitted value will be wrapped in [FetcherResult.Data]. if an exception is returned then - * it will be wrapped in [FetcherResult.Error]. Exceptions thrown in [singleFactory] itself are - * not caught and will be returned to the caller. - * - * Use when creating a [Store] that fetches objects in a single response per request network - * protocol (e.g Http). - * - * @param singleFactory a factory for a [Single] source of network records. - */ -fun Fetcher.Companion.ofSingle(singleFactory: (key: Key) -> Single): Fetcher = - ofFlowable { key: Key -> singleFactory(key).toFlowable() } diff --git a/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxSourceOfTruth.kt b/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxSourceOfTruth.kt deleted file mode 100644 index 68e18adc5..000000000 --- a/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxSourceOfTruth.kt +++ /dev/null @@ -1,63 +0,0 @@ -package org.mobilenativefoundation.store.rx2 - -import io.reactivex.Completable -import io.reactivex.Flowable -import io.reactivex.Maybe -import kotlinx.coroutines.reactive.asFlow -import kotlinx.coroutines.rx2.await -import kotlinx.coroutines.rx2.awaitSingleOrNull -import org.mobilenativefoundation.store.store5.SourceOfTruth - -/** - * Creates a [Maybe] source of truth that is accessible via [reader], [writer], [delete] and - * [deleteAll]. - * - * @param reader function for reading records from the source of truth - * @param writer function for writing updates to the backing source of truth - * @param delete function for deleting records in the source of truth for the given key - * @param deleteAll function for deleting all records in the source of truth - * - */ -fun SourceOfTruth.Companion.ofMaybe( - reader: (Key) -> Maybe, - writer: (Key, Local) -> Completable, - delete: ((Key) -> Completable)? = null, - deleteAll: (() -> Completable)? = null, -): SourceOfTruth { - val deleteFun: (suspend (Key) -> Unit)? = - if (delete != null) { key -> delete(key).await() } else null - val deleteAllFun: (suspend () -> Unit)? = deleteAll?.let { { deleteAll().await() } } - return of( - nonFlowReader = { key -> reader.invoke(key).awaitSingleOrNull() }, - writer = { key, output -> writer.invoke(key, output).await() }, - delete = deleteFun, - deleteAll = deleteAllFun, - ) -} - -/** - * Creates a ([Flowable]) source of truth that is accessed via [reader], [writer], [delete] and - * [deleteAll]. - * - * @param reader function for reading records from the source of truth - * @param writer function for writing updates to the backing source of truth - * @param delete function for deleting records in the source of truth for the given key - * @param deleteAll function for deleting all records in the source of truth - * - */ -fun SourceOfTruth.Companion.ofFlowable( - reader: (Key) -> Flowable, - writer: (Key, Local) -> Completable, - delete: ((Key) -> Completable)? = null, - deleteAll: (() -> Completable)? = null, -): SourceOfTruth { - val deleteFun: (suspend (Key) -> Unit)? = - if (delete != null) { key -> delete(key).await() } else null - val deleteAllFun: (suspend () -> Unit)? = deleteAll?.let { { deleteAll().await() } } - return of( - reader = { key -> reader.invoke(key).asFlow() }, - writer = { key, output -> writer.invoke(key, output).await() }, - delete = deleteFun, - deleteAll = deleteAllFun, - ) -} diff --git a/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxStore.kt b/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxStore.kt deleted file mode 100644 index b411961bb..000000000 --- a/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxStore.kt +++ /dev/null @@ -1,46 +0,0 @@ -package org.mobilenativefoundation.store.rx2 - -import io.reactivex.Completable -import io.reactivex.Flowable -import kotlinx.coroutines.rx2.asFlowable -import kotlinx.coroutines.rx2.rxCompletable -import kotlinx.coroutines.rx2.rxSingle -import org.mobilenativefoundation.store.core5.ExperimentalStoreApi -import org.mobilenativefoundation.store.store5.Store -import org.mobilenativefoundation.store.store5.StoreBuilder -import org.mobilenativefoundation.store.store5.StoreReadRequest -import org.mobilenativefoundation.store.store5.StoreReadResponse -import org.mobilenativefoundation.store.store5.impl.extensions.fresh -import org.mobilenativefoundation.store.store5.impl.extensions.get - -/** - * Return a [Flowable] for the given key - * @param request - see [StoreReadRequest] for configurations - */ -fun Store.observe(request: StoreReadRequest): Flowable> = - stream(request).asFlowable() - -/** - * Purge a particular entry from memory and disk cache. - * Persistent storage will only be cleared if a delete function was passed to - * [StoreBuilder.persister] or [StoreBuilder.nonFlowingPersister] when creating the [Store]. - */ -fun Store.observeClear(key: Key): Completable = rxCompletable { clear(key) } - -/** - * Purge all entries from memory and disk cache. - * Persistent storage will only be cleared if a deleteAll function was passed to - * [StoreBuilder.persister] or [StoreBuilder.nonFlowingPersister] when creating the [Store]. - */ -@ExperimentalStoreApi -fun Store.observeClearAll(): Completable = rxCompletable { clear() } - -/** - * Helper factory that will return data as a [Single] for [key] if it is cached otherwise will return fresh/network data (updating your caches) - */ -fun Store.getSingle(key: Key) = rxSingle { this@getSingle.get(key) } - -/** - * Helper factory that will return fresh data as a [Single] for [key] while updating your caches - */ -fun Store.freshSingle(key: Key) = rxSingle { this@freshSingle.fresh(key) } diff --git a/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxStoreBuilder.kt b/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxStoreBuilder.kt deleted file mode 100644 index 5d378c3d4..000000000 --- a/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxStoreBuilder.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.mobilenativefoundation.store.rx2 - -import io.reactivex.Scheduler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.rx2.asCoroutineDispatcher -import org.mobilenativefoundation.store.store5.Store -import org.mobilenativefoundation.store.store5.StoreBuilder - -/** - * A store multicasts same [Output] value to many consumers (Similar to RxJava.share()), by default - * [Store] will open a global scope for management of shared responses, if instead you'd like to control - * the scheduler that sharing/multicasting happens in you can pass a @param [scheduler] - * - * Note this does not control what scheduler a response is emitted on but rather what thread/scheduler - * to use when managing in flight responses. This is usually used for things like testing where you - * may want to confine to a scheduler backed by a single thread executor - * - * @param scheduler - scheduler to use for sharing - * if a scheduler is not set Store will use [GlobalScope] - */ -fun StoreBuilder.withScheduler(scheduler: Scheduler): StoreBuilder { - return scope(CoroutineScope(scheduler.asCoroutineDispatcher())) -} diff --git a/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/FlowTestExt.kt b/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/FlowTestExt.kt deleted file mode 100644 index 791126c41..000000000 --- a/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/FlowTestExt.kt +++ /dev/null @@ -1,88 +0,0 @@ -package org.mobilenativefoundation.store.rx2.test - -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import com.google.common.truth.FailureMetadata -import com.google.common.truth.Subject -import com.google.common.truth.Truth -import com.google.common.truth.Truth.assertWithMessage -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.async -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceUntilIdle - -@OptIn(ExperimentalCoroutinesApi::class) -internal fun TestScope.assertThat(flow: Flow): FlowSubject { - return Truth.assertAbout(FlowSubject.Factory(this)).that(flow) -} - -@OptIn(ExperimentalCoroutinesApi::class) -internal class FlowSubject constructor( - failureMetadata: FailureMetadata, - private val testCoroutineScope: TestScope, - private val actual: Flow, -) : Subject(failureMetadata, actual) { - /** - * Takes all items in the flow that are available by collecting on it as long as there are - * active jobs in the given [TestCoroutineScope]. - * - * It ensures all expected items are dispatched as well as no additional unexpected items are - * dispatched. - */ - suspend fun emitsExactly(vararg expected: T) { - val collectedSoFar = mutableListOf() - val collectionCoroutine = - testCoroutineScope.async { - actual.collect { - collectedSoFar.add(it) - if (collectedSoFar.size > expected.size) { - assertWithMessage("Too many emissions in the flow (only first additional item is shown)") - .that(collectedSoFar) - .isEqualTo(expected) - } - } - } - testCoroutineScope.advanceUntilIdle() - if (!collectionCoroutine.isActive) { - collectionCoroutine.getCompletionExceptionOrNull()?.let { - throw it - } - } - collectionCoroutine.cancelAndJoin() - assertWithMessage("Flow didn't exactly emit expected items") - .that(collectedSoFar) - .isEqualTo(expected.toList()) - } - - class Factory( - private val testCoroutineScope: TestScope, - ) : Subject.Factory, Flow> { - override fun createSubject( - metadata: FailureMetadata, - actual: Flow?, - ): FlowSubject { - return FlowSubject( - failureMetadata = metadata, - actual = actual ?: emptyFlow(), - testCoroutineScope = testCoroutineScope, - ) - } - } -} diff --git a/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/HotRxSingleStoreTest.kt b/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/HotRxSingleStoreTest.kt deleted file mode 100644 index 8ce70f641..000000000 --- a/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/HotRxSingleStoreTest.kt +++ /dev/null @@ -1,83 +0,0 @@ -package org.mobilenativefoundation.store.rx2.test - -import com.google.common.truth.Truth.assertThat -import io.reactivex.Single -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.test.runTest -import org.junit.Test -import org.junit.runner.RunWith -import org.junit.runners.JUnit4 -import org.mobilenativefoundation.store.rx2.ofResultSingle -import org.mobilenativefoundation.store.store5.Fetcher -import org.mobilenativefoundation.store.store5.FetcherResult -import org.mobilenativefoundation.store.store5.StoreBuilder -import org.mobilenativefoundation.store.store5.StoreReadRequest -import org.mobilenativefoundation.store.store5.StoreReadResponse -import org.mobilenativefoundation.store.store5.StoreReadResponseOrigin - -@RunWith(JUnit4::class) -@FlowPreview -@ExperimentalCoroutinesApi -class HotRxSingleStoreTest { - @Test - fun `GIVEN a hot fetcher WHEN two cached and one fresh call THEN fetcher is only called twice`() = - runTest { - val fetcher: FakeRxFetcher> = - FakeRxFetcher( - 3 to FetcherResult.Data("three-1"), - 3 to FetcherResult.Data("three-2"), - ) - val pipeline = - StoreBuilder.from(Fetcher.ofResultSingle { fetcher.fetch(it) }) - .scope(this) - .build() - - assertThat(pipeline.stream(StoreReadRequest.cached(3, refresh = false))) - .emitsExactly( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - StoreReadResponse.Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - ) - assertThat( - pipeline.stream(StoreReadRequest.cached(3, refresh = false)), - ).emitsExactly( - StoreReadResponse.Data( - value = "three-1", - origin = StoreReadResponseOrigin.Cache, - ), - ) - - assertThat(pipeline.stream(StoreReadRequest.fresh(3))) - .emitsExactly( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - StoreReadResponse.Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - ) - } -} - -class FakeRxFetcher( - vararg val responses: Pair, -) { - private var index = 0 - - @Suppress("RedundantSuspendModifier") // needed for function reference - fun fetch(key: Key): Single { - // will throw if fetcher called more than twice - if (index >= responses.size) { - throw AssertionError("unexpected fetch request") - } - val pair = responses[index++] - assertThat(pair.first).isEqualTo(key) - return Single.just(pair.second) - } -} diff --git a/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/RxFlowableStoreTest.kt b/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/RxFlowableStoreTest.kt deleted file mode 100644 index d7ec5d998..000000000 --- a/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/RxFlowableStoreTest.kt +++ /dev/null @@ -1,118 +0,0 @@ -package org.mobilenativefoundation.store.rx2.test - -import io.reactivex.BackpressureStrategy -import io.reactivex.Completable -import io.reactivex.Flowable -import io.reactivex.schedulers.TestScheduler -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import org.junit.Test -import org.junit.runner.RunWith -import org.junit.runners.JUnit4 -import org.mobilenativefoundation.store.rx2.observe -import org.mobilenativefoundation.store.rx2.ofFlowable -import org.mobilenativefoundation.store.rx2.ofResultFlowable -import org.mobilenativefoundation.store.rx2.withScheduler -import org.mobilenativefoundation.store.store5.Fetcher -import org.mobilenativefoundation.store.store5.FetcherResult -import org.mobilenativefoundation.store.store5.SourceOfTruth -import org.mobilenativefoundation.store.store5.StoreBuilder -import org.mobilenativefoundation.store.store5.StoreReadRequest -import org.mobilenativefoundation.store.store5.StoreReadResponse -import org.mobilenativefoundation.store.store5.StoreReadResponseOrigin -import java.util.concurrent.atomic.AtomicInteger - -@RunWith(JUnit4::class) -@FlowPreview -@ExperimentalCoroutinesApi -class RxFlowableStoreTest { - private val testScheduler = TestScheduler() - private val atomicInteger = AtomicInteger(0) - private val fakeDisk = mutableMapOf() - private val store = - StoreBuilder.from( - fetcher = - Fetcher.ofResultFlowable { - Flowable.create( - { emitter -> - emitter.onNext( - FetcherResult.Data("$it ${atomicInteger.incrementAndGet()} occurrence"), - ) - emitter.onNext( - FetcherResult.Data("$it ${atomicInteger.incrementAndGet()} occurrence"), - ) - emitter.onComplete() - }, - BackpressureStrategy.BUFFER, - ) - }, - sourceOfTruth = - SourceOfTruth.ofFlowable( - reader = { - if (fakeDisk[it] != null) { - Flowable.fromCallable { fakeDisk[it]!! } - } else { - Flowable.empty() - } - }, - writer = { key, value -> - Completable.fromAction { fakeDisk[key] = value } - }, - ), - ) - .withScheduler(testScheduler) - .build() - - @Test - fun simpleTest() { - val testSubscriber1 = - store.observe(StoreReadRequest.fresh(3)) - .subscribeOn(testScheduler) - .test() - testScheduler.triggerActions() - testSubscriber1 - .awaitCount(3) - .assertValues( - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data("3 1 occurrence", StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data("3 2 occurrence", StoreReadResponseOrigin.Fetcher()), - ) - - val testSubscriber2 = - store.observe(StoreReadRequest.cached(3, false)) - .subscribeOn(testScheduler) - .test() - testScheduler.triggerActions() - testSubscriber2 - .awaitCount(2) - .assertValues( - StoreReadResponse.Data("3 2 occurrence", StoreReadResponseOrigin.Cache), - StoreReadResponse.Data("3 2 occurrence", StoreReadResponseOrigin.SourceOfTruth), - ) - - val testSubscriber3 = - store.observe(StoreReadRequest.fresh(3)) - .subscribeOn(testScheduler) - .test() - testScheduler.triggerActions() - testSubscriber3 - .awaitCount(3) - .assertValues( - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data("3 3 occurrence", StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data("3 4 occurrence", StoreReadResponseOrigin.Fetcher()), - ) - - val testSubscriber4 = - store.observe(StoreReadRequest.cached(3, false)) - .subscribeOn(testScheduler) - .test() - testScheduler.triggerActions() - testSubscriber4 - .awaitCount(2) - .assertValues( - StoreReadResponse.Data("3 4 occurrence", StoreReadResponseOrigin.Cache), - StoreReadResponse.Data("3 4 occurrence", StoreReadResponseOrigin.SourceOfTruth), - ) - } -} diff --git a/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/RxSingleStoreExtensionsTest.kt b/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/RxSingleStoreExtensionsTest.kt deleted file mode 100644 index 6ddee0df1..000000000 --- a/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/RxSingleStoreExtensionsTest.kt +++ /dev/null @@ -1,80 +0,0 @@ -package org.mobilenativefoundation.store.rx2.test - -import io.reactivex.Completable -import io.reactivex.Maybe -import io.reactivex.Single -import io.reactivex.schedulers.Schedulers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import org.junit.Test -import org.junit.runner.RunWith -import org.junit.runners.JUnit4 -import org.mobilenativefoundation.store.core5.ExperimentalStoreApi -import org.mobilenativefoundation.store.rx2.freshSingle -import org.mobilenativefoundation.store.rx2.getSingle -import org.mobilenativefoundation.store.rx2.ofMaybe -import org.mobilenativefoundation.store.rx2.ofResultSingle -import org.mobilenativefoundation.store.rx2.withScheduler -import org.mobilenativefoundation.store.store5.Fetcher -import org.mobilenativefoundation.store.store5.FetcherResult -import org.mobilenativefoundation.store.store5.SourceOfTruth -import org.mobilenativefoundation.store.store5.StoreBuilder -import java.util.concurrent.atomic.AtomicInteger - -@ExperimentalStoreApi -@RunWith(JUnit4::class) -@FlowPreview -@ExperimentalCoroutinesApi -class RxSingleStoreExtensionsTest { - private val atomicInteger = AtomicInteger(0) - private var fakeDisk = mutableMapOf() - private val store = - StoreBuilder.from( - fetcher = - Fetcher.ofResultSingle { - Single.fromCallable { FetcherResult.Data("$it ${atomicInteger.incrementAndGet()}") } - }, - sourceOfTruth = - SourceOfTruth.ofMaybe( - reader = { Maybe.fromCallable { fakeDisk[it] } }, - writer = { key, value -> - Completable.fromAction { fakeDisk[key] = value } - }, - delete = { key -> - Completable.fromAction { fakeDisk.remove(key) } - }, - deleteAll = { - Completable.fromAction { fakeDisk.clear() } - }, - ), - ) - .withScheduler(Schedulers.trampoline()) - .build() - - @Test - fun `store rx extension tests`() { - // Return from cache - after initial fetch - store.getSingle(3) - .test() - .await() - .assertValue("3 1") - - // Return from cache - store.getSingle(3) - .test() - .await() - .assertValue("3 1") - - // Return from fresh - forcing a new fetch - store.freshSingle(3) - .test() - .await() - .assertValue("3 2") - - // Return from cache - different to initial - store.getSingle(3) - .test() - .await() - .assertValue("3 2") - } -} diff --git a/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/RxSingleStoreTest.kt b/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/RxSingleStoreTest.kt deleted file mode 100644 index 6276514de..000000000 --- a/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/RxSingleStoreTest.kt +++ /dev/null @@ -1,131 +0,0 @@ -package org.mobilenativefoundation.store.rx2.test - -import io.reactivex.Completable -import io.reactivex.Maybe -import io.reactivex.Single -import io.reactivex.schedulers.Schedulers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import org.junit.Test -import org.junit.runner.RunWith -import org.junit.runners.JUnit4 -import org.mobilenativefoundation.store.core5.ExperimentalStoreApi -import org.mobilenativefoundation.store.rx2.observe -import org.mobilenativefoundation.store.rx2.observeClear -import org.mobilenativefoundation.store.rx2.observeClearAll -import org.mobilenativefoundation.store.rx2.ofMaybe -import org.mobilenativefoundation.store.rx2.ofResultSingle -import org.mobilenativefoundation.store.rx2.withScheduler -import org.mobilenativefoundation.store.store5.Fetcher -import org.mobilenativefoundation.store.store5.FetcherResult -import org.mobilenativefoundation.store.store5.SourceOfTruth -import org.mobilenativefoundation.store.store5.StoreBuilder -import org.mobilenativefoundation.store.store5.StoreReadRequest -import org.mobilenativefoundation.store.store5.StoreReadResponse -import org.mobilenativefoundation.store.store5.StoreReadResponseOrigin -import java.util.concurrent.atomic.AtomicInteger - -@ExperimentalStoreApi -@RunWith(JUnit4::class) -@FlowPreview -@ExperimentalCoroutinesApi -class RxSingleStoreTest { - private val atomicInteger = AtomicInteger(0) - private var fakeDisk = mutableMapOf() - private val store = - StoreBuilder.from( - fetcher = - Fetcher.ofResultSingle { - Single.fromCallable { FetcherResult.Data("$it ${atomicInteger.incrementAndGet()}") } - }, - sourceOfTruth = - SourceOfTruth.ofMaybe( - reader = { Maybe.fromCallable { fakeDisk[it] } }, - writer = { key, value -> - Completable.fromAction { fakeDisk[key] = value } - }, - delete = { key -> - Completable.fromAction { fakeDisk.remove(key) } - }, - deleteAll = { - Completable.fromAction { fakeDisk.clear() } - }, - ), - ) - .withScheduler(Schedulers.trampoline()) - .build() - - @Test - fun simpleTest() { - store.observe(StoreReadRequest.cached(3, false)) - .test() - .awaitCount(2) - .assertValues( - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data("3 1", StoreReadResponseOrigin.Fetcher()), - ) - - store.observe(StoreReadRequest.cached(3, false)) - .test() - .awaitCount(2) - .assertValues( - StoreReadResponse.Data("3 1", StoreReadResponseOrigin.Cache), - StoreReadResponse.Data("3 1", StoreReadResponseOrigin.SourceOfTruth), - ) - - store.observe(StoreReadRequest.fresh(3)) - .test() - .awaitCount(2) - .assertValues( - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data("3 2", StoreReadResponseOrigin.Fetcher()), - ) - - store.observe(StoreReadRequest.cached(3, false)) - .test() - .awaitCount(2) - .assertValues( - StoreReadResponse.Data("3 2", StoreReadResponseOrigin.Cache), - StoreReadResponse.Data("3 2", StoreReadResponseOrigin.SourceOfTruth), - ) - } - - @Test - fun `GIVEN a store with persister values WHEN observeClear is Called THEN next Store get hits network`() { - fakeDisk[3] = "seeded occurrence" - - store.observeClear(3).blockingGet() - - store.observe(StoreReadRequest.cached(3, false)) - .test() - .awaitCount(2) - .assertValues( - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data("3 1", StoreReadResponseOrigin.Fetcher()), - ) - } - - @Test - fun `GIVEN a store with persister values WHEN observeClearAll is called THEN next Store get calls both hit network`() { - fakeDisk[3] = "seeded occurrence" - fakeDisk[4] = "another seeded occurrence" - - store.observeClearAll().blockingGet() - - store.observe(StoreReadRequest.cached(3, false)) - .test() - .awaitCount(2) - .assertValues( - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data("3 1", StoreReadResponseOrigin.Fetcher()), - ) - - store.observe(StoreReadRequest.cached(4, false)) - .test() - .awaitCount(2) - .assertValues( - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data("4 2", StoreReadResponseOrigin.Fetcher()), - ) - } -} diff --git a/settings.gradle b/settings.gradle index 2ff947f5a..1d5b749c1 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,29 +1,54 @@ pluginManagement { includeBuild("tooling") repositories { - mavenCentral() gradlePluginPortal() + mavenCentral() google() } } plugins { - id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" } enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") -dependencyResolutionManagement { - repositories { - mavenCentral() - google() - } -} - rootProject.name = "Store5" -include ':store' -include ':cache' -include ':multicast' -include ':rx2' -include ':core' +include ':store6-core' +include ':store6-quickstart' +include ':store6-swift-dumps-objc' +project(':store6-swift-dumps-objc').projectDir = file('store6-swift-dumps/objc') +include ':store6-swift-dumps-skie' +project(':store6-swift-dumps-skie').projectDir = file('store6-swift-dumps/skie') +include ':store6-swift-dumps-mutations-objc' +project(':store6-swift-dumps-mutations-objc').projectDir = file('store6-swift-dumps/mutations-objc') +include ':store6-swift-dumps-mutations-skie' +project(':store6-swift-dumps-mutations-skie').projectDir = file('store6-swift-dumps/mutations-skie') +include ':store6-extension-probe' +include ':store6-testing' +include ':store6-sqldelight' +include ':store6-sqldelight-sample' +project(':store6-sqldelight-sample').projectDir = file('store6-sqldelight/sample') +include ':store6-compose' +include ':store6-compose-demo' +include ':store6-room' +include ':store6-room-sample' +project(':store6-room-sample').projectDir = file('store6-room/sample') +include ':store6-paging-androidx' +include ':store6-paging-androidx-sample' +project(':store6-paging-androidx-sample').projectDir = file('store6-paging-androidx/sample') +include ':store6-graphql' +include ':store6-graphql-sample' +project(':store6-graphql-sample').projectDir = file('store6-graphql/sample') +include ':store6-realtime' +include ':store6-realtime-sample' +project(':store6-realtime-sample').projectDir = file('store6-realtime/sample') +include ':store6-benchmarks' +include ':store6-devtools' +include ':store6-devtools-inspector' +include ':store6-devtools-demo' +include ':store6-mutations' +include ':store6-mutations-quickstart' +include ':store6-mutations-testing' +include ':store6-mutations-sqldelight' diff --git a/store/api/jvm/store.api b/store/api/jvm/store.api deleted file mode 100644 index d15cdd9d7..000000000 --- a/store/api/jvm/store.api +++ /dev/null @@ -1,697 +0,0 @@ -public abstract interface class org/mobilenativefoundation/store/store5/Bookkeeper { - public static final field Companion Lorg/mobilenativefoundation/store/store5/Bookkeeper$Companion; - public abstract fun clear (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun clearAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun getLastFailedSync (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun setLastFailedSync (Ljava/lang/Object;JLkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static synthetic fun setLastFailedSync$default (Lorg/mobilenativefoundation/store/store5/Bookkeeper;Ljava/lang/Object;JLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; -} - -public final class org/mobilenativefoundation/store/store5/Bookkeeper$Companion { - public final fun by (Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store/store5/Bookkeeper; -} - -public final class org/mobilenativefoundation/store/store5/Bookkeeper$DefaultImpls { - public static synthetic fun setLastFailedSync$default (Lorg/mobilenativefoundation/store/store5/Bookkeeper;Ljava/lang/Object;JLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; -} - -public abstract interface class org/mobilenativefoundation/store/store5/Clear { -} - -public abstract interface class org/mobilenativefoundation/store/store5/Clear$All { - public abstract fun clear (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -} - -public abstract interface class org/mobilenativefoundation/store/store5/Clear$Key { - public abstract fun clear (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -} - -public abstract interface class org/mobilenativefoundation/store/store5/Converter { - public abstract fun fromNetworkToLocal (Ljava/lang/Object;)Ljava/lang/Object; - public abstract fun fromOutputToLocal (Ljava/lang/Object;)Ljava/lang/Object; -} - -public final class org/mobilenativefoundation/store/store5/Converter$Builder { - public field fromNetworkToLocal Lkotlin/jvm/functions/Function1; - public field fromOutputToLocal Lkotlin/jvm/functions/Function1; - public fun ()V - public final fun build ()Lorg/mobilenativefoundation/store/store5/Converter; - public final fun fromNetworkToLocal (Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store/store5/Converter$Builder; - public final fun fromOutputToLocal (Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store/store5/Converter$Builder; - public final fun getFromNetworkToLocal ()Lkotlin/jvm/functions/Function1; - public final fun getFromOutputToLocal ()Lkotlin/jvm/functions/Function1; - public final fun setFromNetworkToLocal (Lkotlin/jvm/functions/Function1;)V - public final fun setFromOutputToLocal (Lkotlin/jvm/functions/Function1;)V -} - -public abstract interface class org/mobilenativefoundation/store/store5/Fetcher { - public static final field Companion Lorg/mobilenativefoundation/store/store5/Fetcher$Companion; - public abstract fun getFallback ()Lorg/mobilenativefoundation/store/store5/Fetcher; - public abstract fun getName ()Ljava/lang/String; - public abstract fun invoke (Ljava/lang/Object;)Lkotlinx/coroutines/flow/Flow; -} - -public final class org/mobilenativefoundation/store/store5/Fetcher$Companion { - public final fun of (Ljava/lang/String;Lkotlin/jvm/functions/Function2;)Lorg/mobilenativefoundation/store/store5/Fetcher; - public static synthetic fun of$default (Lorg/mobilenativefoundation/store/store5/Fetcher$Companion;Ljava/lang/String;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/Fetcher; - public final fun ofFlow (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store/store5/Fetcher; - public static synthetic fun ofFlow$default (Lorg/mobilenativefoundation/store/store5/Fetcher$Companion;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/Fetcher; - public final fun ofFlowWithFallback (Ljava/lang/String;Lorg/mobilenativefoundation/store/store5/Fetcher;Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store/store5/Fetcher; - public final fun ofResult (Lkotlin/jvm/functions/Function2;)Lorg/mobilenativefoundation/store/store5/Fetcher; - public final fun ofResultFlow (Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store/store5/Fetcher; - public final fun ofResultFlowWithFallback (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lorg/mobilenativefoundation/store/store5/Fetcher;)Lorg/mobilenativefoundation/store/store5/Fetcher; - public final fun ofResultWithFallback (Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lorg/mobilenativefoundation/store/store5/Fetcher;)Lorg/mobilenativefoundation/store/store5/Fetcher; - public final fun withFallback (Ljava/lang/String;Lorg/mobilenativefoundation/store/store5/Fetcher;Lkotlin/jvm/functions/Function2;)Lorg/mobilenativefoundation/store/store5/Fetcher; -} - -public abstract class org/mobilenativefoundation/store/store5/FetcherResult { -} - -public final class org/mobilenativefoundation/store/store5/FetcherResult$Data : org/mobilenativefoundation/store/store5/FetcherResult { - public fun (Ljava/lang/Object;Ljava/lang/String;)V - public synthetic fun (Ljava/lang/Object;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/Object; - public final fun component2 ()Ljava/lang/String; - public final fun copy (Ljava/lang/Object;Ljava/lang/String;)Lorg/mobilenativefoundation/store/store5/FetcherResult$Data; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/FetcherResult$Data;Ljava/lang/Object;Ljava/lang/String;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/FetcherResult$Data; - public fun equals (Ljava/lang/Object;)Z - public final fun getOrigin ()Ljava/lang/String; - public final fun getValue ()Ljava/lang/Object; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public abstract class org/mobilenativefoundation/store/store5/FetcherResult$Error : org/mobilenativefoundation/store/store5/FetcherResult { -} - -public final class org/mobilenativefoundation/store/store5/FetcherResult$Error$Custom : org/mobilenativefoundation/store/store5/FetcherResult$Error { - public fun (Ljava/lang/Object;)V - public final fun component1 ()Ljava/lang/Object; - public final fun copy (Ljava/lang/Object;)Lorg/mobilenativefoundation/store/store5/FetcherResult$Error$Custom; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/FetcherResult$Error$Custom;Ljava/lang/Object;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/FetcherResult$Error$Custom; - public fun equals (Ljava/lang/Object;)Z - public final fun getError ()Ljava/lang/Object; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/FetcherResult$Error$Exception : org/mobilenativefoundation/store/store5/FetcherResult$Error { - public fun (Ljava/lang/Throwable;)V - public final fun component1 ()Ljava/lang/Throwable; - public final fun copy (Ljava/lang/Throwable;)Lorg/mobilenativefoundation/store/store5/FetcherResult$Error$Exception; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/FetcherResult$Error$Exception;Ljava/lang/Throwable;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/FetcherResult$Error$Exception; - public fun equals (Ljava/lang/Object;)Z - public final fun getError ()Ljava/lang/Throwable; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/FetcherResult$Error$Message : org/mobilenativefoundation/store/store5/FetcherResult$Error { - public fun (Ljava/lang/String;)V - public final fun component1 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;)Lorg/mobilenativefoundation/store/store5/FetcherResult$Error$Message; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/FetcherResult$Error$Message;Ljava/lang/String;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/FetcherResult$Error$Message; - public fun equals (Ljava/lang/Object;)Z - public final fun getMessage ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public abstract interface class org/mobilenativefoundation/store/store5/Logger { - public abstract fun debug (Ljava/lang/String;)V - public abstract fun error (Ljava/lang/String;Ljava/lang/Throwable;)V - public static synthetic fun error$default (Lorg/mobilenativefoundation/store/store5/Logger;Ljava/lang/String;Ljava/lang/Throwable;ILjava/lang/Object;)V -} - -public final class org/mobilenativefoundation/store/store5/Logger$DefaultImpls { - public static synthetic fun error$default (Lorg/mobilenativefoundation/store/store5/Logger;Ljava/lang/String;Ljava/lang/Throwable;ILjava/lang/Object;)V -} - -public final class org/mobilenativefoundation/store/store5/MemoryPolicy { - public static final field Companion Lorg/mobilenativefoundation/store/store5/MemoryPolicy$Companion; - public static final field DEFAULT_SIZE_POLICY J - public final fun getExpireAfterAccess-UwyO8pc ()J - public final fun getExpireAfterWrite-UwyO8pc ()J - public final fun getHasAccessPolicy ()Z - public final fun getHasMaxSize ()Z - public final fun getHasMaxWeight ()Z - public final fun getHasWritePolicy ()Z - public final fun getMaxSize ()J - public final fun getMaxWeight ()J - public final fun getWeigher ()Lorg/mobilenativefoundation/store/store5/Weigher; - public final fun isDefaultWritePolicy ()Z -} - -public final class org/mobilenativefoundation/store/store5/MemoryPolicy$Companion { - public final fun builder ()Lorg/mobilenativefoundation/store/store5/MemoryPolicy$MemoryPolicyBuilder; - public final fun getDEFAULT_DURATION_POLICY-UwyO8pc ()J -} - -public final class org/mobilenativefoundation/store/store5/MemoryPolicy$MemoryPolicyBuilder { - public fun ()V - public final fun build ()Lorg/mobilenativefoundation/store/store5/MemoryPolicy; - public final fun setExpireAfterAccess-LRDsOJo (J)Lorg/mobilenativefoundation/store/store5/MemoryPolicy$MemoryPolicyBuilder; - public final fun setExpireAfterWrite-LRDsOJo (J)Lorg/mobilenativefoundation/store/store5/MemoryPolicy$MemoryPolicyBuilder; - public final fun setMaxSize (J)Lorg/mobilenativefoundation/store/store5/MemoryPolicy$MemoryPolicyBuilder; - public final fun setWeigherAndMaxWeight (Lorg/mobilenativefoundation/store/store5/Weigher;J)Lorg/mobilenativefoundation/store/store5/MemoryPolicy$MemoryPolicyBuilder; -} - -public abstract interface class org/mobilenativefoundation/store/store5/MutableStore : org/mobilenativefoundation/store/store5/Clear, org/mobilenativefoundation/store/store5/Clear$Key, org/mobilenativefoundation/store/store5/Read$StreamWithConflictResolution, org/mobilenativefoundation/store/store5/Write, org/mobilenativefoundation/store/store5/Write$Stream { -} - -public abstract interface class org/mobilenativefoundation/store/store5/MutableStoreBuilder { - public static final field Companion Lorg/mobilenativefoundation/store/store5/MutableStoreBuilder$Companion; - public abstract fun build (Lorg/mobilenativefoundation/store/store5/Updater;Lorg/mobilenativefoundation/store/store5/Bookkeeper;)Lorg/mobilenativefoundation/store/store5/MutableStore; - public static synthetic fun build$default (Lorg/mobilenativefoundation/store/store5/MutableStoreBuilder;Lorg/mobilenativefoundation/store/store5/Updater;Lorg/mobilenativefoundation/store/store5/Bookkeeper;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/MutableStore; - public abstract fun cachePolicy (Lorg/mobilenativefoundation/store/store5/MemoryPolicy;)Lorg/mobilenativefoundation/store/store5/MutableStoreBuilder; - public abstract fun disableCache ()Lorg/mobilenativefoundation/store/store5/MutableStoreBuilder; - public abstract fun scope (Lkotlinx/coroutines/CoroutineScope;)Lorg/mobilenativefoundation/store/store5/MutableStoreBuilder; - public abstract fun validator (Lorg/mobilenativefoundation/store/store5/Validator;)Lorg/mobilenativefoundation/store/store5/MutableStoreBuilder; -} - -public final class org/mobilenativefoundation/store/store5/MutableStoreBuilder$Companion { - public final fun from (Lorg/mobilenativefoundation/store/store5/Fetcher;Lorg/mobilenativefoundation/store/store5/SourceOfTruth;Lorg/mobilenativefoundation/store/store5/Converter;)Lorg/mobilenativefoundation/store/store5/MutableStoreBuilder; -} - -public final class org/mobilenativefoundation/store/store5/MutableStoreBuilder$DefaultImpls { - public static synthetic fun build$default (Lorg/mobilenativefoundation/store/store5/MutableStoreBuilder;Lorg/mobilenativefoundation/store/store5/Updater;Lorg/mobilenativefoundation/store/store5/Bookkeeper;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/MutableStore; -} - -public final class org/mobilenativefoundation/store/store5/OnFetcherCompletion { - public fun (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V - public final fun component1 ()Lkotlin/jvm/functions/Function1; - public final fun component2 ()Lkotlin/jvm/functions/Function1; - public final fun copy (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store/store5/OnFetcherCompletion; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/OnFetcherCompletion;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/OnFetcherCompletion; - public fun equals (Ljava/lang/Object;)Z - public final fun getOnFailure ()Lkotlin/jvm/functions/Function1; - public final fun getOnSuccess ()Lkotlin/jvm/functions/Function1; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/OnUpdaterCompletion { - public fun (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V - public final fun component1 ()Lkotlin/jvm/functions/Function1; - public final fun component2 ()Lkotlin/jvm/functions/Function1; - public final fun copy (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store/store5/OnUpdaterCompletion; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/OnUpdaterCompletion;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/OnUpdaterCompletion; - public fun equals (Ljava/lang/Object;)Z - public final fun getOnFailure ()Lkotlin/jvm/functions/Function1; - public final fun getOnSuccess ()Lkotlin/jvm/functions/Function1; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public abstract interface class org/mobilenativefoundation/store/store5/Read { -} - -public abstract interface class org/mobilenativefoundation/store/store5/Read$Stream { - public abstract fun stream (Lorg/mobilenativefoundation/store/store5/StoreReadRequest;)Lkotlinx/coroutines/flow/Flow; -} - -public abstract interface class org/mobilenativefoundation/store/store5/Read$StreamWithConflictResolution { - public abstract fun stream (Lorg/mobilenativefoundation/store/store5/StoreReadRequest;)Lkotlinx/coroutines/flow/Flow; -} - -public abstract interface class org/mobilenativefoundation/store/store5/SourceOfTruth { - public static final field Companion Lorg/mobilenativefoundation/store/store5/SourceOfTruth$Companion; - public abstract fun delete (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun deleteAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun reader (Ljava/lang/Object;)Lkotlinx/coroutines/flow/Flow; - public abstract fun write (Ljava/lang/Object;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -} - -public final class org/mobilenativefoundation/store/store5/SourceOfTruth$Companion { - public final fun of (Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store/store5/SourceOfTruth; - public static synthetic fun of$default (Lorg/mobilenativefoundation/store/store5/SourceOfTruth$Companion;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/SourceOfTruth; - public final fun ofFlow (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store/store5/SourceOfTruth; - public static synthetic fun ofFlow$default (Lorg/mobilenativefoundation/store/store5/SourceOfTruth$Companion;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/SourceOfTruth; -} - -public final class org/mobilenativefoundation/store/store5/SourceOfTruth$ReadException : java/lang/RuntimeException { - public fun (Ljava/lang/Object;Ljava/lang/Throwable;)V - public fun equals (Ljava/lang/Object;)Z - public final fun getKey ()Ljava/lang/Object; - public fun hashCode ()I -} - -public final class org/mobilenativefoundation/store/store5/SourceOfTruth$WriteException : java/lang/RuntimeException { - public fun (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Throwable;)V - public fun equals (Ljava/lang/Object;)Z - public final fun getKey ()Ljava/lang/Object; - public final fun getValue ()Ljava/lang/Object; - public fun hashCode ()I -} - -public abstract interface class org/mobilenativefoundation/store/store5/Store : org/mobilenativefoundation/store/store5/Clear$All, org/mobilenativefoundation/store/store5/Clear$Key, org/mobilenativefoundation/store/store5/Read$Stream { -} - -public abstract interface class org/mobilenativefoundation/store/store5/StoreBuilder { - public static final field Companion Lorg/mobilenativefoundation/store/store5/StoreBuilder$Companion; - public abstract fun build ()Lorg/mobilenativefoundation/store/store5/Store; - public abstract fun cachePolicy (Lorg/mobilenativefoundation/store/store5/MemoryPolicy;)Lorg/mobilenativefoundation/store/store5/StoreBuilder; - public abstract fun disableCache ()Lorg/mobilenativefoundation/store/store5/StoreBuilder; - public abstract fun scope (Lkotlinx/coroutines/CoroutineScope;)Lorg/mobilenativefoundation/store/store5/StoreBuilder; - public abstract fun toMutableStoreBuilder (Lorg/mobilenativefoundation/store/store5/Converter;)Lorg/mobilenativefoundation/store/store5/MutableStoreBuilder; - public abstract fun validator (Lorg/mobilenativefoundation/store/store5/Validator;)Lorg/mobilenativefoundation/store/store5/StoreBuilder; -} - -public final class org/mobilenativefoundation/store/store5/StoreBuilder$Companion { - public final fun from (Lorg/mobilenativefoundation/store/store5/Fetcher;)Lorg/mobilenativefoundation/store/store5/StoreBuilder; - public final fun from (Lorg/mobilenativefoundation/store/store5/Fetcher;Lorg/mobilenativefoundation/store/store5/SourceOfTruth;)Lorg/mobilenativefoundation/store/store5/StoreBuilder; - public final fun from (Lorg/mobilenativefoundation/store/store5/Fetcher;Lorg/mobilenativefoundation/store/store5/SourceOfTruth;Lorg/mobilenativefoundation/store/cache5/Cache;)Lorg/mobilenativefoundation/store/store5/StoreBuilder; - public final fun from (Lorg/mobilenativefoundation/store/store5/Fetcher;Lorg/mobilenativefoundation/store/store5/SourceOfTruth;Lorg/mobilenativefoundation/store/cache5/Cache;Lorg/mobilenativefoundation/store/store5/Converter;)Lorg/mobilenativefoundation/store/store5/StoreBuilder; - public final fun from (Lorg/mobilenativefoundation/store/store5/Fetcher;Lorg/mobilenativefoundation/store/store5/SourceOfTruth;Lorg/mobilenativefoundation/store/store5/Converter;)Lorg/mobilenativefoundation/store/store5/StoreBuilder; -} - -public final class org/mobilenativefoundation/store/store5/StoreReadRequest { - public static final field Companion Lorg/mobilenativefoundation/store/store5/StoreReadRequest$Companion; - public final fun component1 ()Ljava/lang/Object; - public final fun component3 ()Z - public final fun component4 ()Z - public final fun component5 ()Z - public final fun copy (Ljava/lang/Object;IZZZ)Lorg/mobilenativefoundation/store/store5/StoreReadRequest; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/StoreReadRequest;Ljava/lang/Object;IZZZILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/StoreReadRequest; - public fun equals (Ljava/lang/Object;)Z - public final fun getFallBackToSourceOfTruth ()Z - public final fun getFetch ()Z - public final fun getKey ()Ljava/lang/Object; - public final fun getRefresh ()Z - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/StoreReadRequest$Companion { - public final fun cached (Ljava/lang/Object;Z)Lorg/mobilenativefoundation/store/store5/StoreReadRequest; - public final fun fresh (Ljava/lang/Object;Z)Lorg/mobilenativefoundation/store/store5/StoreReadRequest; - public static synthetic fun fresh$default (Lorg/mobilenativefoundation/store/store5/StoreReadRequest$Companion;Ljava/lang/Object;ZILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/StoreReadRequest; - public final fun freshWithFallBackToSourceOfTruth (Ljava/lang/Object;)Lorg/mobilenativefoundation/store/store5/StoreReadRequest; - public final fun localOnly (Ljava/lang/Object;)Lorg/mobilenativefoundation/store/store5/StoreReadRequest; - public final fun skipMemory (Ljava/lang/Object;Z)Lorg/mobilenativefoundation/store/store5/StoreReadRequest; -} - -public abstract class org/mobilenativefoundation/store/store5/StoreReadResponse { - public final fun dataOrNull ()Ljava/lang/Object; - public final fun errorMessageOrNull ()Ljava/lang/String; - public final fun errorOrNull ()Ljava/lang/Object; - public abstract fun getOrigin ()Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin; - public final fun requireData ()Ljava/lang/Object; - public final fun throwIfError ()V -} - -public final class org/mobilenativefoundation/store/store5/StoreReadResponse$Data : org/mobilenativefoundation/store/store5/StoreReadResponse { - public fun (Ljava/lang/Object;Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin;)V - public final fun component1 ()Ljava/lang/Object; - public final fun component2 ()Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin; - public final fun copy (Ljava/lang/Object;Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin;)Lorg/mobilenativefoundation/store/store5/StoreReadResponse$Data; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/StoreReadResponse$Data;Ljava/lang/Object;Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/StoreReadResponse$Data; - public fun equals (Ljava/lang/Object;)Z - public fun getOrigin ()Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin; - public final fun getValue ()Ljava/lang/Object; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public abstract class org/mobilenativefoundation/store/store5/StoreReadResponse$Error : org/mobilenativefoundation/store/store5/StoreReadResponse { -} - -public final class org/mobilenativefoundation/store/store5/StoreReadResponse$Error$Custom : org/mobilenativefoundation/store/store5/StoreReadResponse$Error { - public fun (Ljava/lang/Object;Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin;)V - public final fun component1 ()Ljava/lang/Object; - public final fun component2 ()Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin; - public final fun copy (Ljava/lang/Object;Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin;)Lorg/mobilenativefoundation/store/store5/StoreReadResponse$Error$Custom; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/StoreReadResponse$Error$Custom;Ljava/lang/Object;Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/StoreReadResponse$Error$Custom; - public fun equals (Ljava/lang/Object;)Z - public final fun getError ()Ljava/lang/Object; - public fun getOrigin ()Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/StoreReadResponse$Error$Exception : org/mobilenativefoundation/store/store5/StoreReadResponse$Error { - public fun (Ljava/lang/Throwable;Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin;)V - public final fun component1 ()Ljava/lang/Throwable; - public final fun component2 ()Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin; - public final fun copy (Ljava/lang/Throwable;Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin;)Lorg/mobilenativefoundation/store/store5/StoreReadResponse$Error$Exception; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/StoreReadResponse$Error$Exception;Ljava/lang/Throwable;Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/StoreReadResponse$Error$Exception; - public fun equals (Ljava/lang/Object;)Z - public final fun getError ()Ljava/lang/Throwable; - public fun getOrigin ()Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/StoreReadResponse$Error$Message : org/mobilenativefoundation/store/store5/StoreReadResponse$Error { - public fun (Ljava/lang/String;Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin; - public final fun copy (Ljava/lang/String;Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin;)Lorg/mobilenativefoundation/store/store5/StoreReadResponse$Error$Message; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/StoreReadResponse$Error$Message;Ljava/lang/String;Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/StoreReadResponse$Error$Message; - public fun equals (Ljava/lang/Object;)Z - public final fun getMessage ()Ljava/lang/String; - public fun getOrigin ()Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/StoreReadResponse$Initial : org/mobilenativefoundation/store/store5/StoreReadResponse { - public static final field INSTANCE Lorg/mobilenativefoundation/store/store5/StoreReadResponse$Initial; - public fun getOrigin ()Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin; -} - -public final class org/mobilenativefoundation/store/store5/StoreReadResponse$Loading : org/mobilenativefoundation/store/store5/StoreReadResponse { - public fun (Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin;)V - public final fun component1 ()Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin; - public final fun copy (Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin;)Lorg/mobilenativefoundation/store/store5/StoreReadResponse$Loading; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/StoreReadResponse$Loading;Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/StoreReadResponse$Loading; - public fun equals (Ljava/lang/Object;)Z - public fun getOrigin ()Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/StoreReadResponse$NoNewData : org/mobilenativefoundation/store/store5/StoreReadResponse { - public fun (Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin;)V - public final fun component1 ()Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin; - public final fun copy (Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin;)Lorg/mobilenativefoundation/store/store5/StoreReadResponse$NoNewData; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/StoreReadResponse$NoNewData;Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/StoreReadResponse$NoNewData; - public fun equals (Ljava/lang/Object;)Z - public fun getOrigin ()Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/StoreReadResponseKt { - public static final fun doThrow (Lorg/mobilenativefoundation/store/store5/StoreReadResponse$Error;)Ljava/lang/Throwable; -} - -public abstract class org/mobilenativefoundation/store/store5/StoreReadResponseOrigin { -} - -public final class org/mobilenativefoundation/store/store5/StoreReadResponseOrigin$Cache : org/mobilenativefoundation/store/store5/StoreReadResponseOrigin { - public static final field INSTANCE Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin$Cache; -} - -public final class org/mobilenativefoundation/store/store5/StoreReadResponseOrigin$Fetcher : org/mobilenativefoundation/store/store5/StoreReadResponseOrigin { - public fun ()V - public fun (Ljava/lang/String;)V - public synthetic fun (Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;)Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin$Fetcher; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin$Fetcher;Ljava/lang/String;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin$Fetcher; - public fun equals (Ljava/lang/Object;)Z - public final fun getName ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/StoreReadResponseOrigin$Initial : org/mobilenativefoundation/store/store5/StoreReadResponseOrigin { - public static final field INSTANCE Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin$Initial; -} - -public final class org/mobilenativefoundation/store/store5/StoreReadResponseOrigin$SourceOfTruth : org/mobilenativefoundation/store/store5/StoreReadResponseOrigin { - public static final field INSTANCE Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin$SourceOfTruth; -} - -public abstract interface class org/mobilenativefoundation/store/store5/StoreWriteRequest { - public static final field Companion Lorg/mobilenativefoundation/store/store5/StoreWriteRequest$Companion; - public abstract fun getCreated ()J - public abstract fun getKey ()Ljava/lang/Object; - public abstract fun getOnCompletions ()Ljava/util/List; - public abstract fun getValue ()Ljava/lang/Object; -} - -public final class org/mobilenativefoundation/store/store5/StoreWriteRequest$Companion { - public final fun of (Ljava/lang/Object;Ljava/lang/Object;Ljava/util/List;J)Lorg/mobilenativefoundation/store/store5/StoreWriteRequest; - public static synthetic fun of$default (Lorg/mobilenativefoundation/store/store5/StoreWriteRequest$Companion;Ljava/lang/Object;Ljava/lang/Object;Ljava/util/List;JILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/StoreWriteRequest; -} - -public abstract class org/mobilenativefoundation/store/store5/StoreWriteResponse { -} - -public abstract class org/mobilenativefoundation/store/store5/StoreWriteResponse$Error : org/mobilenativefoundation/store/store5/StoreWriteResponse { -} - -public final class org/mobilenativefoundation/store/store5/StoreWriteResponse$Error$Exception : org/mobilenativefoundation/store/store5/StoreWriteResponse$Error { - public fun (Ljava/lang/Throwable;)V - public final fun component1 ()Ljava/lang/Throwable; - public final fun copy (Ljava/lang/Throwable;)Lorg/mobilenativefoundation/store/store5/StoreWriteResponse$Error$Exception; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/StoreWriteResponse$Error$Exception;Ljava/lang/Throwable;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/StoreWriteResponse$Error$Exception; - public fun equals (Ljava/lang/Object;)Z - public final fun getError ()Ljava/lang/Throwable; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/StoreWriteResponse$Error$Message : org/mobilenativefoundation/store/store5/StoreWriteResponse$Error { - public fun (Ljava/lang/String;)V - public final fun component1 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;)Lorg/mobilenativefoundation/store/store5/StoreWriteResponse$Error$Message; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/StoreWriteResponse$Error$Message;Ljava/lang/String;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/StoreWriteResponse$Error$Message; - public fun equals (Ljava/lang/Object;)Z - public final fun getMessage ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public abstract class org/mobilenativefoundation/store/store5/StoreWriteResponse$Success : org/mobilenativefoundation/store/store5/StoreWriteResponse { -} - -public final class org/mobilenativefoundation/store/store5/StoreWriteResponse$Success$Typed : org/mobilenativefoundation/store/store5/StoreWriteResponse$Success { - public fun (Ljava/lang/Object;)V - public final fun component1 ()Ljava/lang/Object; - public final fun copy (Ljava/lang/Object;)Lorg/mobilenativefoundation/store/store5/StoreWriteResponse$Success$Typed; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/StoreWriteResponse$Success$Typed;Ljava/lang/Object;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/StoreWriteResponse$Success$Typed; - public fun equals (Ljava/lang/Object;)Z - public final fun getValue ()Ljava/lang/Object; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/StoreWriteResponse$Success$Untyped : org/mobilenativefoundation/store/store5/StoreWriteResponse$Success { - public fun (Ljava/lang/Object;)V - public final fun component1 ()Ljava/lang/Object; - public final fun copy (Ljava/lang/Object;)Lorg/mobilenativefoundation/store/store5/StoreWriteResponse$Success$Untyped; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/StoreWriteResponse$Success$Untyped;Ljava/lang/Object;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/StoreWriteResponse$Success$Untyped; - public fun equals (Ljava/lang/Object;)Z - public final fun getValue ()Ljava/lang/Object; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public abstract interface class org/mobilenativefoundation/store/store5/Updater { - public static final field Companion Lorg/mobilenativefoundation/store/store5/Updater$Companion; - public abstract fun getOnCompletion ()Lorg/mobilenativefoundation/store/store5/OnUpdaterCompletion; - public abstract fun post (Ljava/lang/Object;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -} - -public final class org/mobilenativefoundation/store/store5/Updater$Companion { - public final fun by (Lkotlin/jvm/functions/Function3;Lorg/mobilenativefoundation/store/store5/OnUpdaterCompletion;)Lorg/mobilenativefoundation/store/store5/Updater; - public static synthetic fun by$default (Lorg/mobilenativefoundation/store/store5/Updater$Companion;Lkotlin/jvm/functions/Function3;Lorg/mobilenativefoundation/store/store5/OnUpdaterCompletion;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/Updater; -} - -public abstract class org/mobilenativefoundation/store/store5/UpdaterResult { -} - -public abstract class org/mobilenativefoundation/store/store5/UpdaterResult$Error : org/mobilenativefoundation/store/store5/UpdaterResult { -} - -public final class org/mobilenativefoundation/store/store5/UpdaterResult$Error$Exception : org/mobilenativefoundation/store/store5/UpdaterResult$Error { - public fun (Ljava/lang/Throwable;)V - public final fun component1 ()Ljava/lang/Throwable; - public final fun copy (Ljava/lang/Throwable;)Lorg/mobilenativefoundation/store/store5/UpdaterResult$Error$Exception; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/UpdaterResult$Error$Exception;Ljava/lang/Throwable;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/UpdaterResult$Error$Exception; - public fun equals (Ljava/lang/Object;)Z - public final fun getError ()Ljava/lang/Throwable; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/UpdaterResult$Error$Message : org/mobilenativefoundation/store/store5/UpdaterResult$Error { - public fun (Ljava/lang/String;)V - public final fun component1 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;)Lorg/mobilenativefoundation/store/store5/UpdaterResult$Error$Message; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/UpdaterResult$Error$Message;Ljava/lang/String;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/UpdaterResult$Error$Message; - public fun equals (Ljava/lang/Object;)Z - public final fun getMessage ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public abstract class org/mobilenativefoundation/store/store5/UpdaterResult$Success : org/mobilenativefoundation/store/store5/UpdaterResult { -} - -public final class org/mobilenativefoundation/store/store5/UpdaterResult$Success$Typed : org/mobilenativefoundation/store/store5/UpdaterResult$Success { - public fun (Ljava/lang/Object;)V - public final fun component1 ()Ljava/lang/Object; - public final fun copy (Ljava/lang/Object;)Lorg/mobilenativefoundation/store/store5/UpdaterResult$Success$Typed; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/UpdaterResult$Success$Typed;Ljava/lang/Object;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/UpdaterResult$Success$Typed; - public fun equals (Ljava/lang/Object;)Z - public final fun getValue ()Ljava/lang/Object; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/UpdaterResult$Success$Untyped : org/mobilenativefoundation/store/store5/UpdaterResult$Success { - public fun (Ljava/lang/Object;)V - public final fun component1 ()Ljava/lang/Object; - public final fun copy (Ljava/lang/Object;)Lorg/mobilenativefoundation/store/store5/UpdaterResult$Success$Untyped; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/UpdaterResult$Success$Untyped;Ljava/lang/Object;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/UpdaterResult$Success$Untyped; - public fun equals (Ljava/lang/Object;)Z - public final fun getValue ()Ljava/lang/Object; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public abstract interface class org/mobilenativefoundation/store/store5/Validator { - public static final field Companion Lorg/mobilenativefoundation/store/store5/Validator$Companion; - public abstract fun isValid (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -} - -public final class org/mobilenativefoundation/store/store5/Validator$Companion { - public final fun by (Lkotlin/jvm/functions/Function2;)Lorg/mobilenativefoundation/store/store5/Validator; -} - -public abstract interface class org/mobilenativefoundation/store/store5/Weigher { - public abstract fun weigh (Ljava/lang/Object;Ljava/lang/Object;)I -} - -public abstract interface class org/mobilenativefoundation/store/store5/Write { - public abstract fun write (Lorg/mobilenativefoundation/store/store5/StoreWriteRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -} - -public abstract interface class org/mobilenativefoundation/store/store5/Write$Stream { - public abstract fun stream (Lkotlinx/coroutines/flow/Flow;)Lkotlinx/coroutines/flow/Flow; -} - -public final class org/mobilenativefoundation/store/store5/impl/OnStoreWriteCompletion { - public fun (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V - public final fun component1 ()Lkotlin/jvm/functions/Function1; - public final fun component2 ()Lkotlin/jvm/functions/Function1; - public final fun copy (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store/store5/impl/OnStoreWriteCompletion; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/impl/OnStoreWriteCompletion;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/impl/OnStoreWriteCompletion; - public fun equals (Ljava/lang/Object;)Z - public final fun getOnFailure ()Lkotlin/jvm/functions/Function1; - public final fun getOnSuccess ()Lkotlin/jvm/functions/Function1; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/impl/RealMutableStoreBuilderKt { - public static final fun mutableStoreBuilderFromFetcher (Lorg/mobilenativefoundation/store/store5/Fetcher;Lorg/mobilenativefoundation/store/store5/Converter;)Lorg/mobilenativefoundation/store/store5/MutableStoreBuilder; - public static final fun mutableStoreBuilderFromFetcherAndSourceOfTruth (Lorg/mobilenativefoundation/store/store5/Fetcher;Lorg/mobilenativefoundation/store/store5/SourceOfTruth;Lorg/mobilenativefoundation/store/store5/Converter;)Lorg/mobilenativefoundation/store/store5/MutableStoreBuilder; - public static final fun mutableStoreBuilderFromFetcherSourceOfTruthAndMemoryCache (Lorg/mobilenativefoundation/store/store5/Fetcher;Lorg/mobilenativefoundation/store/store5/SourceOfTruth;Lorg/mobilenativefoundation/store/cache5/Cache;Lorg/mobilenativefoundation/store/store5/Converter;)Lorg/mobilenativefoundation/store/store5/MutableStoreBuilder; -} - -public final class org/mobilenativefoundation/store/store5/impl/RealStoreBuilderKt { - public static final fun storeBuilderFromFetcher (Lorg/mobilenativefoundation/store/store5/Fetcher;Lorg/mobilenativefoundation/store/store5/SourceOfTruth;)Lorg/mobilenativefoundation/store/store5/StoreBuilder; - public static synthetic fun storeBuilderFromFetcher$default (Lorg/mobilenativefoundation/store/store5/Fetcher;Lorg/mobilenativefoundation/store/store5/SourceOfTruth;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/StoreBuilder; - public static final fun storeBuilderFromFetcherAndSourceOfTruth (Lorg/mobilenativefoundation/store/store5/Fetcher;Lorg/mobilenativefoundation/store/store5/SourceOfTruth;)Lorg/mobilenativefoundation/store/store5/StoreBuilder; - public static final fun storeBuilderFromFetcherSourceOfTruthAndMemoryCache (Lorg/mobilenativefoundation/store/store5/Fetcher;Lorg/mobilenativefoundation/store/store5/SourceOfTruth;Lorg/mobilenativefoundation/store/cache5/Cache;)Lorg/mobilenativefoundation/store/store5/StoreBuilder; - public static final fun storeBuilderFromFetcherSourceOfTruthMemoryCacheAndConverter (Lorg/mobilenativefoundation/store/store5/Fetcher;Lorg/mobilenativefoundation/store/store5/SourceOfTruth;Lorg/mobilenativefoundation/store/cache5/Cache;Lorg/mobilenativefoundation/store/store5/Converter;)Lorg/mobilenativefoundation/store/store5/StoreBuilder; -} - -public final class org/mobilenativefoundation/store/store5/impl/RealStoreWriteRequest : org/mobilenativefoundation/store/store5/StoreWriteRequest { - public fun (Ljava/lang/Object;Ljava/lang/Object;JLjava/util/List;)V - public final fun component1 ()Ljava/lang/Object; - public final fun component2 ()Ljava/lang/Object; - public final fun component3 ()J - public final fun component4 ()Ljava/util/List; - public final fun copy (Ljava/lang/Object;Ljava/lang/Object;JLjava/util/List;)Lorg/mobilenativefoundation/store/store5/impl/RealStoreWriteRequest; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/impl/RealStoreWriteRequest;Ljava/lang/Object;Ljava/lang/Object;JLjava/util/List;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/impl/RealStoreWriteRequest; - public fun equals (Ljava/lang/Object;)Z - public fun getCreated ()J - public fun getKey ()Ljava/lang/Object; - public fun getOnCompletions ()Ljava/util/List; - public fun getValue ()Ljava/lang/Object; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/impl/extensions/StoreKt { - public static final fun asMutableStore (Lorg/mobilenativefoundation/store/store5/Store;Lorg/mobilenativefoundation/store/store5/Updater;Lorg/mobilenativefoundation/store/store5/Bookkeeper;)Lorg/mobilenativefoundation/store/store5/MutableStore; - public static final fun fresh (Lorg/mobilenativefoundation/store/store5/MutableStore;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static final fun fresh (Lorg/mobilenativefoundation/store/store5/Store;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static final fun get (Lorg/mobilenativefoundation/store/store5/MutableStore;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static final fun get (Lorg/mobilenativefoundation/store/store5/Store;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -} - -public abstract class org/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult { -} - -public abstract class org/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Error : org/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult { -} - -public final class org/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Error$Exception : org/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Error { - public fun (Ljava/lang/Throwable;)V - public final fun component1 ()Ljava/lang/Throwable; - public final fun copy (Ljava/lang/Throwable;)Lorg/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Error$Exception; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Error$Exception;Ljava/lang/Throwable;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Error$Exception; - public fun equals (Ljava/lang/Object;)Z - public final fun getError ()Ljava/lang/Throwable; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Error$Message : org/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Error { - public fun (Ljava/lang/String;)V - public final fun component1 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;)Lorg/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Error$Message; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Error$Message;Ljava/lang/String;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Error$Message; - public fun equals (Ljava/lang/Object;)Z - public final fun getMessage ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public abstract class org/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Success : org/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult { -} - -public final class org/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Success$ConflictsResolved : org/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Success { - public fun (Lorg/mobilenativefoundation/store/store5/UpdaterResult$Success;)V - public final fun component1 ()Lorg/mobilenativefoundation/store/store5/UpdaterResult$Success; - public final fun copy (Lorg/mobilenativefoundation/store/store5/UpdaterResult$Success;)Lorg/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Success$ConflictsResolved; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Success$ConflictsResolved;Lorg/mobilenativefoundation/store/store5/UpdaterResult$Success;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Success$ConflictsResolved; - public fun equals (Ljava/lang/Object;)Z - public final fun getValue ()Lorg/mobilenativefoundation/store/store5/UpdaterResult$Success; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Success$NoConflicts : org/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Success { - public static final field INSTANCE Lorg/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Success$NoConflicts; -} - -public abstract class org/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult { -} - -public abstract class org/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult$Error : org/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult { -} - -public final class org/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult$Error$Exception : org/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult$Error { - public fun (Ljava/lang/Throwable;)V - public final fun component1 ()Ljava/lang/Throwable; - public final fun copy (Ljava/lang/Throwable;)Lorg/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult$Error$Exception; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult$Error$Exception;Ljava/lang/Throwable;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult$Error$Exception; - public fun equals (Ljava/lang/Object;)Z - public final fun getError ()Ljava/lang/Throwable; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult$Error$Message : org/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult$Error { - public fun (Ljava/lang/String;)V - public final fun component1 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;)Lorg/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult$Error$Message; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult$Error$Message;Ljava/lang/String;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult$Error$Message; - public fun equals (Ljava/lang/Object;)Z - public final fun getError ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult$Success : org/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult { - public static final field INSTANCE Lorg/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult$Success; -} - diff --git a/store/build.gradle.kts b/store/build.gradle.kts deleted file mode 100644 index 900c73d61..000000000 --- a/store/build.gradle.kts +++ /dev/null @@ -1,47 +0,0 @@ -plugins { - id("org.mobilenativefoundation.store.multiplatform") - alias(libs.plugins.kover) -} - -kotlin { - sourceSets { - commonMain { - dependencies { - implementation(libs.kotlin.stdlib) - implementation(libs.kotlinx.coroutines.core) - implementation(libs.kotlinx.serialization.core) - api(libs.kotlinx.atomic.fu) - implementation(libs.touchlab.kermit) - implementation(projects.multicast) - implementation(projects.cache) - api(projects.core) - } - } - - commonTest { - dependencies { - implementation(libs.junit) - implementation(libs.kotlinx.coroutines.test) - implementation(libs.turbine) - } - } - } -} - -kotlin { - android { - namespace = "org.mobilenativefoundation.store.store5" - } -} - -kover { - - reports { - total { - xml { - onCheck = true - xmlFile.set(file("${layout.buildDirectory}/reports/kover/coverage.xml")) - } - } - } -} diff --git a/store/config/ktlint/baseline.xml b/store/config/ktlint/baseline.xml deleted file mode 100644 index 0642aee1c..000000000 --- a/store/config/ktlint/baseline.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/store/gradle.properties b/store/gradle.properties deleted file mode 100644 index 5a52f2f4e..000000000 --- a/store/gradle.properties +++ /dev/null @@ -1,3 +0,0 @@ -POM_NAME=org.mobilenativefoundation.store -POM_ARTIFACT_ID=store5 -POM_PACKAGING=jar \ No newline at end of file diff --git a/store/src/androidMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.android.kt b/store/src/androidMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.android.kt deleted file mode 100644 index 08d6e9b49..000000000 --- a/store/src/androidMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.android.kt +++ /dev/null @@ -1,3 +0,0 @@ -package org.mobilenativefoundation.store.store5.impl.extensions - -internal actual fun currentTimeMillis(): Long = System.currentTimeMillis() diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Bookkeeper.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Bookkeeper.kt deleted file mode 100644 index 35b3e42db..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Bookkeeper.kt +++ /dev/null @@ -1,32 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import org.mobilenativefoundation.store.store5.impl.RealBookkeeper -import org.mobilenativefoundation.store.store5.impl.RealMutableStore -import org.mobilenativefoundation.store.store5.impl.extensions.now - -/** - * Tracks when local changes fail to sync with network. - * @see [RealMutableStore] usage to persist write request failures and eagerly resolve conflicts before completing a read request. - */ - -interface Bookkeeper { - suspend fun getLastFailedSync(key: Key): Long? - - suspend fun setLastFailedSync( - key: Key, - timestamp: Long = now(), - ): Boolean - - suspend fun clear(key: Key): Boolean - - suspend fun clearAll(): Boolean - - companion object { - fun by( - getLastFailedSync: suspend (key: Key) -> Long?, - setLastFailedSync: suspend (key: Key, timestamp: Long) -> Boolean, - clear: suspend (key: Key) -> Boolean, - clearAll: suspend () -> Boolean, - ): Bookkeeper = RealBookkeeper(getLastFailedSync, setLastFailedSync, clear, clearAll) - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Clear.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Clear.kt deleted file mode 100644 index 0311d7455..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Clear.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import org.mobilenativefoundation.store.core5.ExperimentalStoreApi - -interface Clear { - interface Key { - /** - * Purge a particular entry from memory and disk cache. - * Persistent storage will only be cleared if a delete function was passed to - * [StoreBuilder.persister] or [StoreBuilder.nonFlowingPersister] when creating the [Store]. - */ - suspend fun clear(key: Key) - } - - interface All { - /** - * Purge all entries from memory and disk cache. - * Persistent storage will only be cleared if a clear function was passed to - * [StoreBuilder.persister] or [StoreBuilder.nonFlowingPersister] when creating the [Store]. - */ - @ExperimentalStoreApi - suspend fun clear() - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Converter.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Converter.kt deleted file mode 100644 index b6bbe74a3..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Converter.kt +++ /dev/null @@ -1,42 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -/** - * Converter is a utility interface that can be used to convert a network or output model to a local model. - * Network to Local conversion is needed when the network model is different what you are saving in - * your Source of Truth. - * Output to Local conversion is needed when you are doing local writes in a MutableStore - * @param Network The network data source model type. This is the type used in [Fetcher] - * @param Output The common model type emitted from Store, typically the type returend from your Source of Truth - * @param Local The local data source model type. This is the type used to save to your Source of Truth - */ -interface Converter { - fun fromNetworkToLocal(network: Network): Local - - fun fromOutputToLocal(output: Output): Local - - class Builder { - lateinit var fromOutputToLocal: ((output: Output) -> Local) - lateinit var fromNetworkToLocal: ((network: Network) -> Local) - - fun build(): Converter = RealConverter(fromOutputToLocal, fromNetworkToLocal) - - fun fromOutputToLocal(converter: (output: Output) -> Local): Builder { - fromOutputToLocal = converter - return this - } - - fun fromNetworkToLocal(converter: (network: Network) -> Local): Builder { - fromNetworkToLocal = converter - return this - } - } -} - -private class RealConverter( - private val fromOutputToLocal: ((output: Output) -> Local), - private val fromNetworkToLocal: ((network: Network) -> Local), -) : Converter { - override fun fromNetworkToLocal(network: Network): Local = fromNetworkToLocal.invoke(network) - - override fun fromOutputToLocal(output: Output): Local = fromOutputToLocal.invoke(output) -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Fetcher.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Fetcher.kt deleted file mode 100644 index 940e1ee20..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Fetcher.kt +++ /dev/null @@ -1,200 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.channelFlow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.map -import org.mobilenativefoundation.store.store5.Fetcher.Companion.of -import org.mobilenativefoundation.store.store5.Fetcher.Companion.ofFlow -import org.mobilenativefoundation.store.store5.Fetcher.Companion.ofResult - -/** - * Fetcher is used by [Store] to fetch network records for a given key. The return type is [Flow] to - * allow for multiple result per request. - * - * Note: Store does not catch exceptions thrown by a [Fetcher]. This is done in order to avoid - * silently swallowing NPEs and such. Use [FetcherResult.Error] to communicate expected errors. - * - * See [ofResult] for easily translating from a regular `suspend` function. - * See [ofFlow], [of] for easily translating to [FetcherResult] (and - * automatically transforming exceptions into [FetcherResult.Error]). - * - * @property name Unique name to enable differentiation when [fallback] exists. - */ -interface Fetcher { - val name: String? - - val fallback: Fetcher? - - /** - * Returns a flow of the item represented by the given [key]. - */ - operator fun invoke(key: Key): Flow> - - companion object { - /** - * "Creates" a [Fetcher] from a [flowFactory]. - * - * Use when creating a [Store] that fetches objects in a multiple responses per request - * network protocol (e.g., Web Sockets). - * - * [Store] does not catch exception thrown in [flowFactory] or in the returned [Flow]. These - * exception will be propagated to the caller. - * - * @param flowFactory a factory for a [Flow]ing source of network records. - */ - fun ofResultFlow(flowFactory: (Key) -> Flow>): Fetcher = - FactoryFetcher(factory = flowFactory) - - /** - * Creates a [Fetcher] with a [fallback] from a [flowFactory]. - * Use instead of [ofResultFlow] if implementing fallback mechanisms. - * @param name Unique name to enable differentiation of fetchers. - */ - fun ofResultFlowWithFallback( - name: String, - flowFactory: (Key) -> Flow>, - fallback: Fetcher, - ): Fetcher = FactoryFetcherWithFallback(name = name, factory = flowFactory, fallback = fallback) - - /** - * "Creates" a [Fetcher] from a non-[Flow] source. - * - * Use when creating a [Store] that fetches objects in a single response per request network - * protocol (e.g., Http). - * - * [Store] does not catch exception thrown in [fetch]. These exception will be propagated to the - * caller. - * - * @param fetch a source of network records. - */ - fun ofResult(fetch: suspend (Key) -> FetcherResult): Fetcher = - ofResultFlow(fetch.asFlow()) - - /** - * Creates a [Fetcher] with a [fallback] from a non-Flow source. - * Use instead of [ofResult] if implementing fallback mechanisms. - * @param name Unique name to enable differentiation of fetchers. - */ - fun ofResultWithFallback( - name: String, - fetch: suspend (Key) -> FetcherResult, - fallback: Fetcher, - ): Fetcher = ofResultFlowWithFallback(name, fetch.asFlow(), fallback) - - /** - * "Creates" a [Fetcher] from a [flowFactory] and translate the results to a [FetcherResult]. - * - * Emitted values will be wrapped in [FetcherResult.Data]. if an exception disrupts the flow then - * it will be wrapped in [FetcherResult.Error]. Exceptions thrown in [flowFactory] itself are not - * caught and will be returned to the caller. - * - * Use when creating a [Store] that fetches objects in a multiple responses per request - * network protocol (e.g Web Sockets). - * - * @param flowFactory a factory for a [Flow]ing source of network records. - */ - fun ofFlow( - name: String? = null, - flowFactory: (Key) -> Flow, - ): Fetcher = - FactoryFetcher { key: Key -> - flowFactory(key) - .map> { FetcherResult.Data(it, name) } - .catch { throwable: Throwable -> emit(FetcherResult.Error.Exception(throwable)) } - } - - /** - * Creates a [Fetcher] with a [fallback] from a [flowFactory]. - * Use instead of [ofFlow] if implementing fallback mechanisms. - * @param name Unique name to enable differentiation of fetchers - */ - fun ofFlowWithFallback( - name: String, - fallback: Fetcher, - flowFactory: (Key) -> Flow, - ): Fetcher = - FactoryFetcherWithFallback(name = name, factory = { key: Key -> - flowFactory(key) - .map> { - FetcherResult.Data(it, name) - } - .catch { throwable: Throwable -> emit(FetcherResult.Error.Exception(throwable)) } - }, fallback = fallback) - - /** - * "Creates" a [Fetcher] from a non-[Flow] source and translate the results to a [FetcherResult]. - * - * Emitted values will be wrapped in [FetcherResult.Data]. if an exception disrupts the flow then - * it will be wrapped in [FetcherResult.Error] - * - * Use when creating a [Store] that fetches objects in a single response per request - * network protocol (e.g Http). - * - * @param fetch a source of network records. - */ - fun of( - name: String? = null, - fetch: suspend (key: Key) -> Network, - ): Fetcher = ofFlow(name, fetch.asFlow()) - - /** - * Creates a [Fetcher] with a [fallback] from a non-Flow source. - * Use instead of [of] if implementing fallback mechanisms. - * @param name Unique name to enable differentiation of fetchers - */ - fun withFallback( - name: String, - fallback: Fetcher, - fetch: suspend (key: Key) -> Network, - ): Fetcher = ofFlowWithFallback(name, fallback, fetch.asFlow()) - - private fun (suspend (key: Key) -> Network).asFlow() = - { key: Key -> - flow { - emit(invoke(key)) - } - } - - private class FactoryFetcher( - private val factory: (Key) -> Flow>, - ) : Fetcher { - override val name: String? = null - override val fallback: Fetcher? = null - - override fun invoke(key: Key): Flow> = factory(key) - } - - private fun tryFetch( - key: Key, - factory: (Key) -> Flow>, - fallback: Fetcher?, - ): Flow> = - channelFlow { - factory(key).collect { fetcherResult -> - when (fetcherResult) { - is FetcherResult.Data -> { - send(fetcherResult) - } - - is FetcherResult.Error -> { - if (fallback != null) { - tryFetch(key, fallback::invoke, fallback.fallback).collect { send(it) } - } else { - send(fetcherResult) - } - } - } - } - } - - private class FactoryFetcherWithFallback( - override val name: String, - private val factory: (Key) -> Flow>, - override val fallback: Fetcher, - ) : Fetcher { - override fun invoke(key: Key): Flow> = tryFetch(key, factory, fallback) - } - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/FetcherResult.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/FetcherResult.kt deleted file mode 100644 index 1160e5416..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/FetcherResult.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -sealed class FetcherResult { - data class Data(val value: Network, val origin: String? = null) : FetcherResult() - - sealed class Error : FetcherResult() { - data class Exception(val error: Throwable) : Error() - - data class Message(val message: String) : Error() - - data class Custom(val error: E) : Error() - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Logger.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Logger.kt deleted file mode 100644 index d7318b9af..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Logger.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -/** - * A simple logging interface for logging error and debug messages. - */ -interface Logger { - /** - * Logs an error message, optionally with a throwable. - * - * @param message The error message to log. - * @param throwable An optional [Throwable] associated with the error. - */ - fun error( - message: String, - throwable: Throwable? = null, - ) - - /** - * Logs a debug message. - * - * @param message The debug message to log. - */ - fun debug(message: String) -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/MemoryPolicy.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/MemoryPolicy.kt deleted file mode 100644 index fe1e816f4..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/MemoryPolicy.kt +++ /dev/null @@ -1,116 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import org.mobilenativefoundation.store.cache5.Cache -import kotlin.time.Duration - -fun interface Weigher { - /** - * Returns the weight of a cache entry. There is no unit for entry weights; rather they are simply - * relative to each other. - * - * @return the weight of the entry; must be non-negative - */ - fun weigh( - key: K, - value: V, - ): Int -} - -internal object OneWeigher : Weigher { - override fun weigh( - key: Any, - value: Any, - ): Int = 1 -} - -/** - * Defines behavior of in-memory [Cache]. - * Used by [Store]. - * @see [Store] - */ -class MemoryPolicy internal constructor( - val expireAfterWrite: Duration, - val expireAfterAccess: Duration, - val maxSize: Long, - val maxWeight: Long, - val weigher: Weigher, -) { - val isDefaultWritePolicy: Boolean = expireAfterWrite == DEFAULT_DURATION_POLICY - - val hasWritePolicy: Boolean = expireAfterWrite != DEFAULT_DURATION_POLICY - - val hasAccessPolicy: Boolean = expireAfterAccess != DEFAULT_DURATION_POLICY - - val hasMaxSize: Boolean = maxSize != DEFAULT_SIZE_POLICY - - val hasMaxWeight: Boolean = maxWeight != DEFAULT_SIZE_POLICY - - class MemoryPolicyBuilder { - private var expireAfterWrite = DEFAULT_DURATION_POLICY - private var expireAfterAccess = DEFAULT_DURATION_POLICY - private var maxSize: Long = DEFAULT_SIZE_POLICY - private var maxWeight: Long = DEFAULT_SIZE_POLICY - private var weigher: Weigher = OneWeigher - - fun setExpireAfterWrite(expireAfterWrite: Duration): MemoryPolicyBuilder = - apply { - check(expireAfterAccess == DEFAULT_DURATION_POLICY) { - "Cannot set expireAfterWrite with expireAfterAccess already set" - } - this.expireAfterWrite = expireAfterWrite - } - - fun setExpireAfterAccess(expireAfterAccess: Duration): MemoryPolicyBuilder = - apply { - check(expireAfterWrite == DEFAULT_DURATION_POLICY) { - "Cannot set expireAfterAccess with expireAfterWrite already set" - } - this.expireAfterAccess = expireAfterAccess - } - - /** - * Sets the maximum number of items ([maxSize]) kept in the cache. - * - * When [maxSize] is 0, entries will be discarded immediately and no values will be cached. - * - * If not set, cache size will be unlimited. - */ - fun setMaxSize(maxSize: Long): MemoryPolicyBuilder = - apply { - check(maxWeight == DEFAULT_SIZE_POLICY && weigher == OneWeigher) { - "Cannot setMaxSize when maxWeight or weigher are already set" - } - check(maxSize >= 0) { "maxSize cannot be negative" } - this.maxSize = maxSize - } - - fun setWeigherAndMaxWeight( - weigher: Weigher, - maxWeight: Long, - ): MemoryPolicyBuilder = - apply { - check(maxSize == DEFAULT_SIZE_POLICY) { - "Cannot setWeigherAndMaxWeight when maxSize already set" - } - check(maxWeight >= 0) { "maxWeight cannot be negative" } - this.weigher = weigher - this.maxWeight = maxWeight - } - - fun build() = - MemoryPolicy( - expireAfterWrite = expireAfterWrite, - expireAfterAccess = expireAfterAccess, - maxSize = maxSize, - maxWeight = maxWeight, - weigher = weigher, - ) - } - - companion object { - val DEFAULT_DURATION_POLICY: Duration = Duration.INFINITE - const val DEFAULT_SIZE_POLICY: Long = -1 - - fun builder(): MemoryPolicyBuilder = MemoryPolicyBuilder() - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/MutableStore.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/MutableStore.kt deleted file mode 100644 index 8018451c5..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/MutableStore.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import org.mobilenativefoundation.store.core5.ExperimentalStoreApi - -@ExperimentalStoreApi -interface MutableStore : - Read.StreamWithConflictResolution, - Write, - Write.Stream, - Clear.Key, - Clear diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/MutableStoreBuilder.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/MutableStoreBuilder.kt deleted file mode 100644 index 21ec51c89..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/MutableStoreBuilder.kt +++ /dev/null @@ -1,53 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import kotlinx.coroutines.CoroutineScope -import org.mobilenativefoundation.store.store5.impl.mutableStoreBuilderFromFetcherAndSourceOfTruth - -interface MutableStoreBuilder { - fun build( - updater: Updater, - bookkeeper: Bookkeeper? = null, - ): MutableStore - - /** - * A store multicasts same [Output] value to many consumers (Similar to RxJava.share()), by default - * [Store] will open a global scope for management of shared responses, if instead you'd like to control - * the scope that sharing/multicasting happens in you can pass a @param [scope] - * - * @param scope - scope to use for sharing - */ - fun scope(scope: CoroutineScope): MutableStoreBuilder - - /** - * controls eviction policy for a store cache, use [MemoryPolicy.MemoryPolicyBuilder] to configure a TTL - * or size based eviction - * Example: MemoryPolicy.builder().setExpireAfterWrite(10.seconds).build() - */ - fun cachePolicy(memoryPolicy: MemoryPolicy?): MutableStoreBuilder - - /** - * by default a Store caches in memory with a default policy of max items = 100 - */ - fun disableCache(): MutableStoreBuilder - - fun validator(validator: Validator): MutableStoreBuilder - - companion object { - /** - * Creates a new [MutableStoreBuilder] from a [Fetcher] and a [SourceOfTruth]. - * - * @param fetcher a function for fetching a flow of network records. - * @param sourceOfTruth a [SourceOfTruth] for the store. - */ - fun from( - fetcher: Fetcher, - sourceOfTruth: SourceOfTruth, - converter: Converter, - ): MutableStoreBuilder = - mutableStoreBuilderFromFetcherAndSourceOfTruth( - fetcher = fetcher, - sourceOfTruth = sourceOfTruth, - converter = converter, - ) - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/OnFetcherCompletion.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/OnFetcherCompletion.kt deleted file mode 100644 index 4ad18f3a1..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/OnFetcherCompletion.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -data class OnFetcherCompletion( - val onSuccess: (FetcherResult.Data) -> Unit, - val onFailure: (FetcherResult.Error) -> Unit, -) diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/OnUpdaterCompletion.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/OnUpdaterCompletion.kt deleted file mode 100644 index 84c8544c5..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/OnUpdaterCompletion.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -data class OnUpdaterCompletion( - val onSuccess: (UpdaterResult.Success) -> Unit, - val onFailure: (UpdaterResult.Error) -> Unit, -) diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Read.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Read.kt deleted file mode 100644 index e5c142b8f..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Read.kt +++ /dev/null @@ -1,17 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import kotlinx.coroutines.flow.Flow - -interface Read { - interface Stream { - /** - * Return a flow for the given key - * @param request - see [StoreReadRequest] for configurations - */ - fun stream(request: StoreReadRequest): Flow> - } - - interface StreamWithConflictResolution { - fun stream(request: StoreReadRequest): Flow> - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/SourceOfTruth.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/SourceOfTruth.kt deleted file mode 100644 index 08a4fe3f8..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/SourceOfTruth.kt +++ /dev/null @@ -1,203 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.mobilenativefoundation.store.store5 - -import kotlinx.coroutines.flow.Flow -import org.mobilenativefoundation.store.store5.impl.PersistentNonFlowingSourceOfTruth -import org.mobilenativefoundation.store.store5.impl.PersistentSourceOfTruth -import kotlin.jvm.JvmName - -/** - * - * [SourceOfTruth], as name implies, is the persistence API which [Store] uses to serve values to - * the collectors. If provided, [Store] will only return values received from [SourceOfTruth] back - * to the collectors. - * - * In other words, values coming from the [Fetcher] will always be sent to the [SourceOfTruth] - * and will be read back via [reader] to be returned to the collector. - * - * This round-trip ensures the data is consistent across the application in case the [Fetcher] may - * not return all fields or return a different class type than the app uses. It is particularly - * useful if your application has a local observable database which is directly modified by the app - * as Store can observe these changes and update the collectors even before value is synced to the - * backend. - * - * Source of truth takes care of making any source (no matter if it has flowing reads or not) into - * a common flowing API. - * - * A source of truth is usually backed by local storage. It's purpose is to eliminate the need - * for waiting on network update before local modifications are available (via [Store.Stream.read]). - * - * For maximal flexibility, [writer]'s record type ([Input]] and [reader]'s record type - * ([Output]) are not identical. This allows us to read one type of objects from network and - * transform them to another type when placing them in local storage. - * - */ -interface SourceOfTruth { - /** - * Used by [Store] to read records from the source of truth. - * - * @param key The key to read for. - */ - fun reader(key: Key): Flow - - /** - * Used by [Store] to write records **coming in from the fetcher (network)** to the source of - * truth. - * - * **Note:** [Store] currently does not support updating the source of truth with local user - * updates (i.e writing record of type [Output]). However, any changes in the local database - * will still be visible via [Store.Stream.read] APIs as long as you are using a local storage that - * supports observability (e.g. Room, SQLDelight, Realm). - * - * @param key The key to update for. - */ - suspend fun write( - key: Key, - value: Local, - ) - - /** - * Used by [Store] to delete records in the source of truth for the given key. - * - * @param key The key to delete for. - */ - suspend fun delete(key: Key) - - /** - * Used by [Store] to delete all records in the source of truth. - */ - suspend fun deleteAll() - - companion object { - /** - * Creates a (non-[Flow]) source of truth that is accessible via [nonFlowReader], [writer], - * [delete] and [deleteAll]. - * - * @param nonFlowReader function for reading records from the source of truth - * @param writer function for writing updates to the backing source of truth - * @param delete function for deleting records in the source of truth for the given key - * @param deleteAll function for deleting all records in the source of truth - */ - fun of( - nonFlowReader: suspend (Key) -> Output?, - writer: suspend (Key, Local) -> Unit, - delete: (suspend (Key) -> Unit)? = null, - deleteAll: (suspend () -> Unit)? = null, - ): SourceOfTruth = - PersistentNonFlowingSourceOfTruth( - realReader = nonFlowReader, - realWriter = writer, - realDelete = delete, - realDeleteAll = deleteAll, - ) - - /** - * Creates a ([Flow]) source of truth that is accessed via [reader], [writer], - * [delete] and [deleteAll]. - * - * @param reader function for reading records from the source of truth - * @param writer function for writing updates to the backing source of truth - * @param delete function for deleting records in the source of truth for the given key - * @param deleteAll function for deleting all records in the source of truth - */ - @JvmName("ofFlow") - fun of( - reader: (Key) -> Flow, - writer: suspend (Key, Local) -> Unit, - delete: (suspend (Key) -> Unit)? = null, - deleteAll: (suspend () -> Unit)? = null, - ): SourceOfTruth = - PersistentSourceOfTruth( - realReader = reader, - realWriter = writer, - realDelete = delete, - realDeleteAll = deleteAll, - ) - } - - /** - * The exception provided when a write operation fails in SourceOfTruth. - * - * see [StoreReadResponse.Error.Exception] - */ - class WriteException( - /** - * The key for the failed write attempt - */ - val key: Any?, // TODO why are we not marking keys non-null ? - /** - * The value for the failed write attempt - */ - val value: Any?, - /** - * The exception thrown from the [SourceOfTruth]'s [write] method. - */ - cause: Throwable, - ) : RuntimeException( - "Failed to write value to Source of Truth. key: $key", - cause, - ) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - - other as WriteException - - if (key != other.key) return false - if (value != other.value) return false - if (cause != other.cause) return false - return true - } - - override fun hashCode(): Int { - var result = key.hashCode() - result = 31 * result + value.hashCode() - return result - } - } - - /** - * Exception created when a [reader] throws an exception. - * - * see [StoreReadResponse.Error.Exception] - */ - class ReadException( - /** - * The key for the failed write attempt - */ - val key: Any?, // TODO shouldn't key be non-null? - cause: Throwable, - ) : RuntimeException( - "Failed to read from Source of Truth. key: $key", - cause, - ) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - - other as ReadException - - if (key != other.key) return false - if (cause != other.cause) return false - return true - } - - override fun hashCode(): Int { - return key.hashCode() - } - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Store.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Store.kt deleted file mode 100644 index 6fc93df2e..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Store.kt +++ /dev/null @@ -1,37 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -/** - * A Store is responsible for managing a particular data request. - * - * When you create an implementation of a Store, you provide it with a Fetcher, a function that defines how data will be fetched over network. - * - * You can also define how your Store will cache data in-memory and on-disk. See [StoreBuilder] for full configuration - * - * Example usage: - * - * val store = StoreBuilder - * .fromNonFlow, List> { (query, config) -> - * provideRetrofit().fetchData(query, config.limit).data.children.map(::toPosts) - * } - * .persister(reader = { (query, _) -> db.postDao().loadData(query) }, - * writer = { (query, _), posts -> db.dataDAO().insertData(query, posts) }, - * delete = { (query, _) -> db.dataDAO().clearData(query) }, - * deleteAll = db.postDao()::clearAllFeeds) - * .build() - * - * // single shot response - * viewModelScope.launch { - * val data = store.fresh(key) - * } - * - * // get cached data and collect future emissions as well - * viewModelScope.launch { - * val data = store.cached(key, refresh=true) - * .collect{data.value=it } - * } - * - */ -interface Store : - Read.Stream, - Clear.Key, - Clear.All diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreBuilder.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreBuilder.kt deleted file mode 100644 index f91b94af4..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreBuilder.kt +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.mobilenativefoundation.store.store5 - -import kotlinx.coroutines.CoroutineScope -import org.mobilenativefoundation.store.cache5.Cache -import org.mobilenativefoundation.store.store5.impl.storeBuilderFromFetcher -import org.mobilenativefoundation.store.store5.impl.storeBuilderFromFetcherAndSourceOfTruth -import org.mobilenativefoundation.store.store5.impl.storeBuilderFromFetcherSourceOfTruthAndMemoryCache -import org.mobilenativefoundation.store.store5.impl.storeBuilderFromFetcherSourceOfTruthMemoryCacheAndConverter - -/** - * Main entry point for creating a [Store]. - */ -interface StoreBuilder { - fun build(): Store - - fun toMutableStoreBuilder( - converter: Converter, - ): MutableStoreBuilder - - /** - * A store multicasts same [Output] value to many consumers (Similar to RxJava.share()), by default - * [Store] will open a global scope for management of shared responses, if instead you'd like to control - * the scope that sharing/multicasting happens in you can pass a @param [scope] - * - * @param scope - scope to use for sharing - */ - fun scope(scope: CoroutineScope): StoreBuilder - - /** - * controls eviction policy for a store cache, use [MemoryPolicy.MemoryPolicyBuilder] to configure a TTL - * or size based eviction - * Example: MemoryPolicy.builder().setExpireAfterWrite(10.seconds).build() - */ - fun cachePolicy(memoryPolicy: MemoryPolicy?): StoreBuilder - - /** - * by default a Store caches in memory with a default policy of max items = 100 - */ - fun disableCache(): StoreBuilder - - fun validator(validator: Validator): StoreBuilder - - companion object { - /** - * Creates a new [StoreBuilder] from a [Fetcher]. - * - * @param fetcher a [Fetcher] flow of network records. - */ - fun from(fetcher: Fetcher): StoreBuilder = - storeBuilderFromFetcher(fetcher = fetcher) - - /** - * Creates a new [StoreBuilder] from a [Fetcher] and a [SourceOfTruth]. - * - * @param fetcher a function for fetching a flow of network records. - * @param sourceOfTruth a [SourceOfTruth] for the store. - */ - fun from( - fetcher: Fetcher, - sourceOfTruth: SourceOfTruth, - ): StoreBuilder = storeBuilderFromFetcherAndSourceOfTruth(fetcher = fetcher, sourceOfTruth = sourceOfTruth) - - fun from( - fetcher: Fetcher, - sourceOfTruth: SourceOfTruth, - memoryCache: Cache, - ): StoreBuilder = - storeBuilderFromFetcherSourceOfTruthAndMemoryCache( - fetcher, - sourceOfTruth, - memoryCache, - ) - - fun from( - fetcher: Fetcher, - sourceOfTruth: SourceOfTruth, - converter: Converter, - ): StoreBuilder = - storeBuilderFromFetcherSourceOfTruthMemoryCacheAndConverter( - fetcher, - sourceOfTruth, - null, - converter, - ) - - fun from( - fetcher: Fetcher, - sourceOfTruth: SourceOfTruth, - memoryCache: Cache, - converter: Converter, - ): StoreBuilder = - storeBuilderFromFetcherSourceOfTruthMemoryCacheAndConverter( - fetcher, - sourceOfTruth, - memoryCache, - converter, - ) - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreDefaults.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreDefaults.kt deleted file mode 100644 index 341f3e579..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreDefaults.kt +++ /dev/null @@ -1,26 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import kotlin.time.Duration -import kotlin.time.Duration.Companion.hours - -internal object StoreDefaults { - /** - * Cache TTL (default is 24 hours), can be overridden - * - * @return memory cache TTL - */ - val cacheTTL: Duration = 24.hours - - /** - * Cache size (default is 100), can be overridden - * - * @return memory cache size - */ - val cacheSize: Long = 100 - - val memoryPolicy = - MemoryPolicy.builder() - .setMaxSize(cacheSize) - .setExpireAfterWrite(cacheTTL) - .build() -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreReadRequest.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreReadRequest.kt deleted file mode 100644 index d31fea5c4..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreReadRequest.kt +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.mobilenativefoundation.store.store5 - -/** - * data class to represent a single store request - * @param key a unique identifier for your data - * @param skippedCaches List of cache types that should be skipped when retuning the response see [CacheType] - * @param refresh If set to true [Store] will always get fresh value from fetcher while also - * starting the stream from the local [com.dropbox.android.external.store4.impl.SourceOfTruth] and memory cache - * @param fetch If set to false, then fetcher will not be used - */ -data class StoreReadRequest private constructor( - val key: Key, - private val skippedCaches: Int, - val refresh: Boolean = false, - val fallBackToSourceOfTruth: Boolean = false, - val fetch: Boolean = true, -) { - internal fun shouldSkipCache(type: CacheType) = skippedCaches.and(type.flag) != 0 - - /** - * Factories for common store requests - */ - companion object { - private val allCaches = - CacheType.values().fold(0) { prev, next -> - prev.or(next.flag) - } - - /** - * Create a [StoreReadRequest] which will skip all caches and hit your fetcher - * (filling your caches). - * - * Note: If the [Fetcher] does not return any data (i.e., the returned - * [kotlinx.coroutines.Flow], when collected, is empty). Then store will fall back to local - * data **even** if you explicitly requested fresh data. - * See https://github.com/dropbox/Store/pull/194 for context. - */ - fun fresh( - key: Key, - fallBackToSourceOfTruth: Boolean = false, - ) = StoreReadRequest( - key = key, - skippedCaches = allCaches, - refresh = true, - fallBackToSourceOfTruth = fallBackToSourceOfTruth, - ) - - /** - * Create a [StoreReadRequest] which will return data from memory/disk caches if present, - * otherwise will hit your fetcher (filling your caches). - * @param refresh if true then return fetcher (new) data as well (updating your caches) - */ - fun cached( - key: Key, - refresh: Boolean, - ) = StoreReadRequest( - key = key, - skippedCaches = 0, - refresh = refresh, - ) - - /** - * Create a [StoreReadRequest] which will return data from memory/disk caches if present, - * otherwise will return [StoreReadResponse.NoNewData] - */ - fun localOnly(key: Key) = - StoreReadRequest( - key = key, - skippedCaches = 0, - fetch = false, - ) - - /** - * Create a [StoreReadRequest] which will return data from disk cache - * @param refresh if true then return fetcher (new) data as well (updating your caches) - */ - fun skipMemory( - key: Key, - refresh: Boolean, - ) = StoreReadRequest( - key = key, - skippedCaches = CacheType.MEMORY.flag, - refresh = refresh, - ) - - /** - * Creates a [StoreReadRequest] skipping all caches and returning data from network on success and data from [SourceOfTruth] on failure. - */ - fun freshWithFallBackToSourceOfTruth(key: Key) = fresh(key, fallBackToSourceOfTruth = true) - } -} - -internal enum class CacheType(internal val flag: Int) { - MEMORY(0b01), - DISK(0b10), -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreReadResponse.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreReadResponse.kt deleted file mode 100644 index edef2ce81..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreReadResponse.kt +++ /dev/null @@ -1,180 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.mobilenativefoundation.store.store5 - -/** - * Holder for responses from Store. - * - * Instead of using regular error channels (a.k.a. throwing exceptions), Store uses this holder - * class to represent each response. This allows the flow to keep running even if an error happens - * so that if there is an observable single source of truth, application can keep observing it. - */ -sealed class StoreReadResponse { - /** - * Represents the source of the Response. - */ - abstract val origin: StoreReadResponseOrigin - - object Initial : StoreReadResponse() { - override val origin: StoreReadResponseOrigin = StoreReadResponseOrigin.Initial - } - - /** - * Loading event dispatched by [Store] to signal the [Fetcher] is in progress. - */ - data class Loading(override val origin: StoreReadResponseOrigin) : StoreReadResponse() - - /** - * Data dispatched by [Store] - */ - data class Data(val value: Output, override val origin: StoreReadResponseOrigin) : - StoreReadResponse() - - /** - * No new data event dispatched by Store to signal the [Fetcher] returned no data (i.e., the - * returned [kotlinx.coroutines.flow.Flow], when collected, was empty). - */ - data class NoNewData(override val origin: StoreReadResponseOrigin) : StoreReadResponse() - - /** - * Error dispatched by a pipeline - */ - sealed class Error : StoreReadResponse() { - data class Exception( - val error: Throwable, - override val origin: StoreReadResponseOrigin, - ) : Error() - - data class Message( - val message: String, - override val origin: StoreReadResponseOrigin, - ) : Error() - - data class Custom( - val error: E, - override val origin: StoreReadResponseOrigin, - ) : Error() - } - - /** - * Returns the available data or throws [NullPointerException] if there is no data. - */ - fun requireData(): Output { - return when (this) { - is Data -> value - is Error -> throw this.doThrow() - else -> throw NullPointerException("there is no data in $this") - } - } - - /** - * If this [StoreReadResponse] is of type [StoreReadResponse.Error], throws the exception - * Otherwise, does nothing. - */ - fun throwIfError() { - if (this is Error) { - throw this.doThrow() - } - } - - /** - * If this [StoreReadResponse] is of type [StoreReadResponse.Error], returns the available error - * from it. Otherwise, returns `null`. - */ - fun errorMessageOrNull(): String? { - return when (this) { - is Error.Message -> message - is Error.Exception -> error.message ?: "exception: ${error::class}" - else -> null - } - } - - /** - * If there is data available, returns it; otherwise returns null. - */ - fun dataOrNull(): Output? = - when (this) { - is Data -> value - else -> null - } - - private fun errorOrNull(): Throwable? { - if (this is Error.Exception) { - return error - } - - return null - } - - /** - * @returns Error if there is one, else null. - */ - @Suppress("UNCHECKED_CAST") - fun errorOrNull(): E? { - if (this is Error.Custom<*>) { - return (this as? Error.Custom)?.error - } - - return errorOrNull() as? E - } - - @Suppress("UNCHECKED_CAST") - internal fun swapType(): StoreReadResponse = - when (this) { - is Error -> this - is Loading -> this - is NoNewData -> this - is Data -> throw RuntimeException("cannot swap type for StoreResponse.Data") - is Initial -> this - } -} - -/** - * Represents the origin for a [StoreReadResponse]. - */ -sealed class StoreReadResponseOrigin { - /** - * [StoreReadResponse] is sent from the cache - */ - object Cache : StoreReadResponseOrigin() - - /** - * [StoreReadResponse] is sent from the persister - */ - object SourceOfTruth : StoreReadResponseOrigin() - - /** - * [StoreReadResponse] is sent from a fetcher - * @property name Unique name to enable differentiation when [org.mobilenativefoundation.store.store5.Fetcher.fallback] exists - */ - data class Fetcher(val name: String? = null) : StoreReadResponseOrigin() - - object Initial : StoreReadResponseOrigin() -} - -fun StoreReadResponse.Error.doThrow(): Throwable { - return when (this) { - is StoreReadResponse.Error.Exception -> error - is StoreReadResponse.Error.Message -> RuntimeException(message) - is StoreReadResponse.Error.Custom<*> -> { - if (error is Throwable) { - error - } else { - RuntimeException("Non-throwable custom error: $error") - } - } - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreWriteRequest.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreWriteRequest.kt deleted file mode 100644 index d76c68ea2..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreWriteRequest.kt +++ /dev/null @@ -1,21 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import org.mobilenativefoundation.store.store5.impl.OnStoreWriteCompletion -import org.mobilenativefoundation.store.store5.impl.RealStoreWriteRequest -import org.mobilenativefoundation.store.store5.impl.extensions.currentTimeMillis - -interface StoreWriteRequest { - val key: Key - val value: Output - val created: Long - val onCompletions: List? - - companion object { - fun of( - key: Key, - value: Output, - onCompletions: List? = null, - created: Long = currentTimeMillis(), - ): StoreWriteRequest = RealStoreWriteRequest(key, value, created, onCompletions) - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreWriteResponse.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreWriteResponse.kt deleted file mode 100644 index d0e6e3844..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreWriteResponse.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -sealed class StoreWriteResponse { - sealed class Success : StoreWriteResponse() { - data class Typed(val value: Response) : Success() - - data class Untyped(val value: Any) : Success() - } - - sealed class Error : StoreWriteResponse() { - data class Exception(val error: Throwable) : Error() - - data class Message(val message: String) : Error() - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Updater.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Updater.kt deleted file mode 100644 index 8913ba619..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Updater.kt +++ /dev/null @@ -1,43 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -typealias PostRequest = suspend (key: Key, value: Output) -> UpdaterResult - -/** - * Posts data to remote data source. - * @see [StoreWriteRequest] - */ -interface Updater { - /** - * Makes HTTP POST request. - */ - suspend fun post( - key: Key, - value: Output, - ): UpdaterResult - - /** - * Executes on network completion. - */ - val onCompletion: OnUpdaterCompletion? - - companion object { - fun by( - post: PostRequest, - onCompletion: OnUpdaterCompletion? = null, - ): Updater = - RealNetworkUpdater( - post, - onCompletion, - ) - } -} - -internal class RealNetworkUpdater( - private val realPost: PostRequest, - override val onCompletion: OnUpdaterCompletion?, -) : Updater { - override suspend fun post( - key: Key, - value: Output, - ): UpdaterResult = realPost(key, value) -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/UpdaterResult.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/UpdaterResult.kt deleted file mode 100644 index 2d6a77614..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/UpdaterResult.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -sealed class UpdaterResult { - sealed class Success : UpdaterResult() { - data class Typed(val value: Response) : Success() - - data class Untyped(val value: Any) : Success() - } - - sealed class Error : UpdaterResult() { - data class Exception(val error: Throwable) : Error() - - data class Message(val message: String) : Error() - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Validator.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Validator.kt deleted file mode 100644 index 274764b12..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Validator.kt +++ /dev/null @@ -1,20 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import org.mobilenativefoundation.store.store5.impl.RealValidator - -/** - * Enables custom validation of [Store] items. - * @see [StoreReadRequest] - */ -interface Validator { - /** - * Determines whether a [Store] item is valid. - * If invalid, [MutableStore] will get the latest network value using [Fetcher]. - * [MutableStore] will not validate network responses. - */ - suspend fun isValid(item: Output): Boolean - - companion object { - fun by(validator: suspend (item: Output) -> Boolean): Validator = RealValidator(validator) - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Write.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Write.kt deleted file mode 100644 index 9b07fa3f1..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Write.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import kotlinx.coroutines.flow.Flow -import org.mobilenativefoundation.store.core5.ExperimentalStoreApi - -interface Write { - @ExperimentalStoreApi - suspend fun write(request: StoreWriteRequest): StoreWriteResponse - - interface Stream { - @ExperimentalStoreApi - fun stream(requestStream: Flow>): Flow - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/DefaultLogger.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/DefaultLogger.kt deleted file mode 100644 index 9219683c3..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/DefaultLogger.kt +++ /dev/null @@ -1,26 +0,0 @@ -package org.mobilenativefoundation.store.store5.impl - -import co.touchlab.kermit.CommonWriter -import org.mobilenativefoundation.store.store5.Logger - -/** - * Default implementation of [Logger] using the Kermit logging library. - */ -internal class DefaultLogger : Logger { - private val delegate = - co.touchlab.kermit.Logger.apply { - setLogWriters(listOf(CommonWriter())) - setTag("Store") - } - - override fun debug(message: String) { - delegate.d(message) - } - - override fun error( - message: String, - throwable: Throwable?, - ) { - delegate.e(message, throwable) - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/FetcherController.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/FetcherController.kt deleted file mode 100644 index d61d492dc..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/FetcherController.kt +++ /dev/null @@ -1,175 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.mobilenativefoundation.store.store5.impl - -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.async -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEmpty -import kotlinx.coroutines.withContext -import org.mobilenativefoundation.store.multicast5.Multicaster -import org.mobilenativefoundation.store.store5.Converter -import org.mobilenativefoundation.store.store5.Fetcher -import org.mobilenativefoundation.store.store5.FetcherResult -import org.mobilenativefoundation.store.store5.SourceOfTruth -import org.mobilenativefoundation.store.store5.StoreReadResponse -import org.mobilenativefoundation.store.store5.StoreReadResponseOrigin - -/** - * This class maintains one and only 1 fetcher for a given [Key]. - * - * Any value emitted by the fetcher is sent into the [sourceOfTruth] before it is dispatched. - * If [sourceOfTruth] is `null`, [enablePiggyback] is set to true by default so that previous - * fetcher requests receives values dispatched by later requests even if they don't share the - * request. - */ -@Suppress("UNCHECKED_CAST") -internal class FetcherController( - /** - * The [CoroutineScope] to use when collecting from the fetcher - */ - private val scope: CoroutineScope, - /** - * The function that provides the actualy fetcher flow when needed - */ - private val realFetcher: Fetcher, - /** - * [SourceOfTruth] to send the data each time fetcher dispatches a value. Can be `null` if - * no [SourceOfTruth] is available. - */ - private val sourceOfTruth: SourceOfTruthWithBarrier?, - private val converter: Converter = - object : - Converter { - override fun fromNetworkToLocal(network: Network): Local { - return network as Local - } - - override fun fromOutputToLocal(output: Output): Local { - throw IllegalStateException("Not used") - } - }, -) { - @Suppress("USELESS_CAST", "UNCHECKED_CAST") // needed for multicaster source - private val fetchers = - RefCountedResource( - create = { key: Key -> - Multicaster( - scope = scope, - bufferSize = 0, - source = - flow { emitAll(realFetcher(key)) }.map { - when (it) { - is FetcherResult.Data -> { - try { - val network = it.value - val local = converter.fromNetworkToLocal(network) - sourceOfTruth?.write(key, local) - StoreReadResponse.Data( - network, - origin = StoreReadResponseOrigin.Fetcher(it.origin), - ) as StoreReadResponse - } catch (exception: CancellationException) { - throw exception - } catch (exception: Throwable) { - StoreReadResponse.Error.Exception( - exception, - origin = StoreReadResponseOrigin.Fetcher(it.origin), - ) - } - } - - is FetcherResult.Error.Message -> - StoreReadResponse.Error.Message( - it.message, - origin = StoreReadResponseOrigin.Fetcher(), - ) - - is FetcherResult.Error.Exception -> - StoreReadResponse.Error.Exception( - it.error, - origin = StoreReadResponseOrigin.Fetcher(), - ) - is FetcherResult.Error.Custom<*> -> - StoreReadResponse.Error.Custom( - it.error, - StoreReadResponseOrigin.Fetcher(), - ) - } - }.onEmpty { - val origin = - StoreReadResponseOrigin.Fetcher() - emit(StoreReadResponse.NoNewData(origin)) - }, - // When enabled, downstream collectors are never closed. - // Instead, they are kept active to receive values dispatched by fetchers created after them. - // This makes FetcherController act like a SourceOfTruth in the lack of a SourceOfTruth provided by the developer. - piggybackingDownstream = true, - onEach = { _ -> - // Exceptions thrown here propagate to the actor and close downstream channels silently. - // This caused store.stream() and store.get() to hang indefinitely (see #660). - // Consequently, we are intentionally performing no work here. - // Conversion and SOT writes now happen in the source flow above. - }, - ) - }, - onRelease = { _: Key, multicaster: Multicaster> -> - multicaster.close() - }, - ) - - fun getFetcher( - key: Key, - piggybackOnly: Boolean = false, - ): Flow> { - return flow { - val fetcher = acquireFetcher(key) - try { - emitAll(fetcher.newDownstream(piggybackOnly)) - } finally { - withContext(NonCancellable) { - fetchers.release(key, fetcher) - } - } - } - } - - /** - * This functions goes to great length to prevent capturing the calling context from - * [getFetcher]. The reason being that the [Flow] returned by [getFetcher] is collected on the - * user's context and [acquireFetcher] will, optionally, launch a long running coroutine on the - * [FetcherController]'s [scope]. In order to avoid capturing a reference to the scope we need - * to: - * 1) Not inline this function as that will cause the lambda to capture a reference to the - * surrounding suspend lambda which, in turn, holds a reference to the user's coroutine context. - * 2) Use [async]-[await] instead of - * [kotlinx.coroutines.withContext] as [kotlinx.coroutines.withContext] will also hold onto a - * reference to the caller's context (the LHS parameter of the new context which is used to run - * the operation). - */ - private suspend fun acquireFetcher(key: Key) = - scope.async { - fetchers.acquire(key) - }.await() - - // visible for testing - internal suspend fun fetcherSize() = fetchers.size() -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/OnStoreWriteCompletion.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/OnStoreWriteCompletion.kt deleted file mode 100644 index 3971b847c..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/OnStoreWriteCompletion.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.mobilenativefoundation.store.store5.impl - -import org.mobilenativefoundation.store.store5.StoreWriteResponse - -data class OnStoreWriteCompletion( - val onSuccess: (StoreWriteResponse.Success) -> Unit, - val onFailure: (StoreWriteResponse.Error) -> Unit, -) diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealBookkeeper.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealBookkeeper.kt deleted file mode 100644 index c0ff2dfef..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealBookkeeper.kt +++ /dev/null @@ -1,22 +0,0 @@ -package org.mobilenativefoundation.store.store5.impl - -import org.mobilenativefoundation.store.store5.Bookkeeper -import org.mobilenativefoundation.store.store5.internal.definition.Timestamp - -internal class RealBookkeeper( - private val realGetLastFailedSync: suspend (key: Key) -> Timestamp?, - private val realSetLastFailedSync: suspend (key: Key, timestamp: Timestamp) -> Boolean, - private val realClear: suspend (key: Key) -> Boolean, - private val realClearAll: suspend () -> Boolean, -) : Bookkeeper { - override suspend fun getLastFailedSync(key: Key): Long? = realGetLastFailedSync(key) - - override suspend fun setLastFailedSync( - key: Key, - timestamp: Long, - ): Boolean = realSetLastFailedSync(key, timestamp) - - override suspend fun clear(key: Key): Boolean = realClear(key) - - override suspend fun clearAll(): Boolean = realClearAll() -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealMutableStore.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealMutableStore.kt deleted file mode 100644 index 83f7af843..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealMutableStore.kt +++ /dev/null @@ -1,347 +0,0 @@ -@file:Suppress("UNCHECKED_CAST") - -package org.mobilenativefoundation.store.store5.impl - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import org.mobilenativefoundation.store.core5.ExperimentalStoreApi -import org.mobilenativefoundation.store.store5.Bookkeeper -import org.mobilenativefoundation.store.store5.Clear -import org.mobilenativefoundation.store.store5.Logger -import org.mobilenativefoundation.store.store5.MutableStore -import org.mobilenativefoundation.store.store5.StoreReadRequest -import org.mobilenativefoundation.store.store5.StoreReadResponse -import org.mobilenativefoundation.store.store5.StoreWriteRequest -import org.mobilenativefoundation.store.store5.StoreWriteResponse -import org.mobilenativefoundation.store.store5.Updater -import org.mobilenativefoundation.store.store5.UpdaterResult -import org.mobilenativefoundation.store.store5.impl.extensions.now -import org.mobilenativefoundation.store.store5.internal.concurrent.ThreadSafety -import org.mobilenativefoundation.store.store5.internal.definition.WriteRequestQueue -import org.mobilenativefoundation.store.store5.internal.result.EagerConflictResolutionResult -import org.mobilenativefoundation.store.store5.internal.result.StoreDelegateWriteResult - -@OptIn(ExperimentalStoreApi::class) -internal class RealMutableStore( - private val delegate: RealStore, - private val updater: Updater, - private val bookkeeper: Bookkeeper?, - private val logger: Logger = DefaultLogger(), -) : MutableStore, Clear.Key by delegate, Clear.All by delegate { - private val storeLock = Mutex() - private val keyToWriteRequestQueue = mutableMapOf>() - private val keyToThreadSafety = mutableMapOf() - - override fun stream(request: StoreReadRequest): Flow> = - flow { - // Ensure we are ready for this key. - safeInitStore(request.key) - - // Try to eagerly resolve conflicts before pulling from network. - when (val eagerConflictResolutionResult = tryEagerlyResolveConflicts(request.key)) { - // TODO(#678): Many use cases will not want to pull immediately after failing to push local changes. - // We should enable configuration of conflict resolution strategies, such as logging, retrying, canceling. - - is EagerConflictResolutionResult.Error.Exception -> { - logger.error(eagerConflictResolutionResult.error.toString()) - } - - is EagerConflictResolutionResult.Error.Message -> { - logger.error(eagerConflictResolutionResult.message) - } - - is EagerConflictResolutionResult.Success.ConflictsResolved -> { - logger.debug(eagerConflictResolutionResult.value.toString()) - } - - EagerConflictResolutionResult.Success.NoConflicts -> { - logger.debug("No conflicts.") - } - } - - // Now, we can just delegate to the underlying stream. - delegate.stream(request).collect { storeReadResponse -> emit(storeReadResponse) } - } - - @ExperimentalStoreApi - override fun stream(requestStream: Flow>): Flow = - flow { - // Each incoming write request is enqueued. - // Then we try to update the network and delegate. - - requestStream - .onEach { writeRequest -> - // Prepare per-key data structures. - safeInitStore(writeRequest.key) - - // Enqueue the new write request. - addWriteRequestToQueue(writeRequest) - } - .collect { writeRequest -> - val storeWriteResponse = - try { - // Always write to local first. - // Only proceed to network if local write succeeded. - when (val delegateWriteResult = delegate.write(writeRequest.key, writeRequest.value)) { - is StoreDelegateWriteResult.Error.Exception -> { - StoreWriteResponse.Error.Exception(delegateWriteResult.error) - } - is StoreDelegateWriteResult.Error.Message -> { - StoreWriteResponse.Error.Message(delegateWriteResult.error) - } - is StoreDelegateWriteResult.Success -> { - // Try to sync to network. - when (val updaterResult = tryUpdateServer(writeRequest)) { - is UpdaterResult.Error.Exception -> StoreWriteResponse.Error.Exception(updaterResult.error) - is UpdaterResult.Error.Message -> StoreWriteResponse.Error.Message(updaterResult.message) - is UpdaterResult.Success.Typed<*> -> { - val typedValue = updaterResult.value as? Response - if (typedValue == null) { - StoreWriteResponse.Success.Untyped(updaterResult.value) - } else { - StoreWriteResponse.Success.Typed(updaterResult.value) - } - } - is UpdaterResult.Success.Untyped -> StoreWriteResponse.Success.Untyped(updaterResult.value) - } - } - } - } catch (throwable: Throwable) { - StoreWriteResponse.Error.Exception(throwable) - } - emit(storeWriteResponse) - } - } - - @ExperimentalStoreApi - override suspend fun write(request: StoreWriteRequest): StoreWriteResponse = - stream(flowOf(request)).first() - - private suspend fun tryUpdateServer(request: StoreWriteRequest): UpdaterResult { - val updaterResult = postLatest(request.key) - - if (updaterResult is UpdaterResult.Success) { - // We successfully synced to network, can now clear out any stale writes. - updateWriteRequestQueue( - key = request.key, - created = request.created, - updaterResult = updaterResult, - ) - bookkeeper?.clear(request.key) - } else { - // Could not sync, need to record a failed timestamp. - bookkeeper?.setLastFailedSync(request.key) - } - - return updaterResult - } - - /** - * Post the very latest write for [key] to the network using [updater]. - */ - private suspend fun postLatest(key: Key): UpdaterResult { - // The "latest" is the last item in the queue for this key. - val writer = getLatestWriteRequest(key) - - return when (val updaterResult = updater.post(key, writer.value)) { - is UpdaterResult.Error.Exception -> UpdaterResult.Error.Exception(updaterResult.error) - is UpdaterResult.Error.Message -> UpdaterResult.Error.Message(updaterResult.message) - is UpdaterResult.Success.Untyped -> UpdaterResult.Success.Untyped(updaterResult.value) - is UpdaterResult.Success.Typed<*> -> { - val typedValue = updaterResult.value as? Response - if (typedValue == null) { - UpdaterResult.Success.Untyped(updaterResult.value) - } else { - UpdaterResult.Success.Typed(updaterResult.value) - } - } - } - } - - /** - * Remove or keep queue items after a successful network sync. - */ - private suspend fun updateWriteRequestQueue( - key: Key, - created: Long, - updaterResult: UpdaterResult.Success, - ) { - val nextWriteRequestQueue = - withWriteRequestQueueLock(key) { - val remaining = ArrayDeque>() - - for (writeRequest in this) { - if (writeRequest.created <= created) { - // Mark each relevant request as succeeded. - updater.onCompletion?.onSuccess?.invoke(updaterResult) - - val storeWriteResponse = - when (updaterResult) { - is UpdaterResult.Success.Typed<*> -> { - val typedValue = updaterResult.value as? Response - if (typedValue == null) { - StoreWriteResponse.Success.Untyped(updaterResult.value) - } else { - StoreWriteResponse.Success.Typed(updaterResult.value) - } - } - - is UpdaterResult.Success.Untyped -> StoreWriteResponse.Success.Untyped(updaterResult.value) - } - - // Notify each on-completion callback. - writeRequest.onCompletions?.forEach { onStoreWriteCompletion -> - onStoreWriteCompletion.onSuccess(storeWriteResponse) - } - } else { - // Keep requests that happened after created. - remaining.add(writeRequest) - } - } - remaining - } - - // Update the in-memory map outside the queue's mutex. - storeLock.withLock { - keyToWriteRequestQueue[key] = nextWriteRequestQueue - } - } - - /** - * Locks the queue for [key] and invokes [block]. - */ - private suspend fun withWriteRequestQueueLock( - key: Key, - block: suspend WriteRequestQueue.() -> Result, - ): Result { - // Acquire the ThreadSafety object for this key without holding storeLock. - val threadSafety = getThreadSafety(key) - - // Exclusively lock the queue's own mutex. The block both reads and structurally mutates the - // per-key ArrayDeque (add / iterate-and-rebuild), so callers must mutually exclude each other. - // A shared/reader lock here would allow a concurrent add() during iteration, corrupting the - // deque's backing array — a ConcurrentModificationException on the JVM and an EXC_BAD_ACCESS - // on Kotlin/Native. - return threadSafety.writeRequests.mutex.withLock { - val queue = getQueue(key) - queue.block() - } - } - - private suspend fun getLatestWriteRequest(key: Key): StoreWriteRequest { - val threadSafety = getThreadSafety(key) - threadSafety.writeRequests.mutex.lock() - return try { - val queue = getQueue(key) - require(queue.isNotEmpty()) { - "No writes found for key=$key." - } - queue.last() - } finally { - threadSafety.writeRequests.mutex.unlock() - } - } - - /** - * Checks if we have un-synced writes or a recorded failed sync for [key]. - */ - private suspend fun conflictsMightExist(key: Key): Boolean { - val failed = bookkeeper?.getLastFailedSync(key) - return (failed != null) || !writeRequestsQueueIsEmpty(key) - } - - private fun writeRequestsQueueIsEmpty(key: Key): Boolean = keyToWriteRequestQueue[key].isNullOrEmpty() - - private suspend fun addWriteRequestToQueue(writeRequest: StoreWriteRequest) = - withWriteRequestQueueLock(writeRequest.key) { - add(writeRequest) - } - - private suspend fun tryEagerlyResolveConflicts(key: Key): EagerConflictResolutionResult { - // Acquire the ThreadSafety object for this key without holding storeLock. - val threadSafety = getThreadSafety(key) - - // Lock just long enough to check if conflicts exist. - val (latestValue, conflictsExist) = - threadSafety.readCompletions.mutex.withLock { - val latestValue = delegate.latestOrNull(key) - val conflictsExist = latestValue != null && bookkeeper != null && conflictsMightExist(key) - latestValue to conflictsExist - } - - return if (!conflictsExist || latestValue == null) { - EagerConflictResolutionResult.Success.NoConflicts - } else { - try { - val updaterResult = - updater.post(key, latestValue).also { updaterResult -> - if (updaterResult is UpdaterResult.Success) { - // If it succeeds, we want to remove stale requests and clear the bookkeeper. - updateWriteRequestQueue(key = key, created = now(), updaterResult = updaterResult) - - bookkeeper?.clear(key) - } - } - - when (updaterResult) { - is UpdaterResult.Error.Exception -> { - EagerConflictResolutionResult.Error.Exception(updaterResult.error) - } - - is UpdaterResult.Error.Message -> { - EagerConflictResolutionResult.Error.Message(updaterResult.message) - } - - is UpdaterResult.Success -> { - EagerConflictResolutionResult.Success.ConflictsResolved(updaterResult) - } - } - } catch (error: Throwable) { - EagerConflictResolutionResult.Error.Exception(error) - } - } - } - - /** - * Ensures that [keyToThreadSafety] and [keyToWriteRequestQueue] have entries for [key]. - * We only hold [storeLock] while touching these two maps, then release it immediately. - */ - private suspend fun safeInitStore(key: Key) { - storeLock.withLock { - if (keyToThreadSafety[key] == null) { - keyToThreadSafety[key] = ThreadSafety() - } - if (keyToWriteRequestQueue[key] == null) { - keyToWriteRequestQueue[key] = ArrayDeque() - } - } - } - - /** - * Retrieves the [ThreadSafety] object for [key] without reinitializing it, since [safeInitStore] handles creation. - * We do a quick [storeLock] read then release it without nesting per-key locks inside [storeLock]. - */ - private suspend fun getThreadSafety(key: Key): ThreadSafety { - return storeLock.withLock { - requireNotNull(keyToThreadSafety[key]) { - "ThreadSafety not initialized for key=$key." - } - } - } - - /** - * Helper to retrieve the queue for [key] without re-initialization logic. - */ - private suspend fun getQueue(key: Key): WriteRequestQueue { - return storeLock.withLock { - requireNotNull(keyToWriteRequestQueue[key]) { - "No write request queue found for key=$key." - } - } - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealMutableStoreBuilder.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealMutableStoreBuilder.kt deleted file mode 100644 index ff9696134..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealMutableStoreBuilder.kt +++ /dev/null @@ -1,109 +0,0 @@ -package org.mobilenativefoundation.store.store5.impl - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.GlobalScope -import org.mobilenativefoundation.store.cache5.Cache -import org.mobilenativefoundation.store.cache5.CacheBuilder -import org.mobilenativefoundation.store.store5.Bookkeeper -import org.mobilenativefoundation.store.store5.Converter -import org.mobilenativefoundation.store.store5.Fetcher -import org.mobilenativefoundation.store.store5.MemoryPolicy -import org.mobilenativefoundation.store.store5.MutableStore -import org.mobilenativefoundation.store.store5.MutableStoreBuilder -import org.mobilenativefoundation.store.store5.SourceOfTruth -import org.mobilenativefoundation.store.store5.Store -import org.mobilenativefoundation.store.store5.StoreDefaults -import org.mobilenativefoundation.store.store5.Updater -import org.mobilenativefoundation.store.store5.Validator -import org.mobilenativefoundation.store.store5.impl.extensions.asMutableStore - -// we don't have a source of truth and can use a dummy converter -fun mutableStoreBuilderFromFetcher( - fetcher: Fetcher, - converter: Converter, -): MutableStoreBuilder = RealMutableStoreBuilder(fetcher, converter = converter) - -fun mutableStoreBuilderFromFetcherAndSourceOfTruth( - fetcher: Fetcher, - sourceOfTruth: SourceOfTruth, - converter: Converter, -): MutableStoreBuilder = RealMutableStoreBuilder(fetcher, sourceOfTruth, converter = converter) - -fun mutableStoreBuilderFromFetcherSourceOfTruthAndMemoryCache( - fetcher: Fetcher, - sourceOfTruth: SourceOfTruth, - memoryCache: Cache, - converter: Converter, -): MutableStoreBuilder = RealMutableStoreBuilder(fetcher, sourceOfTruth, memoryCache, converter = converter) - -internal class RealMutableStoreBuilder( - private val fetcher: Fetcher, - private val sourceOfTruth: SourceOfTruth? = null, - private val memoryCache: Cache? = null, - private val converter: Converter, -) : MutableStoreBuilder { - private var scope: CoroutineScope? = null - private var cachePolicy: MemoryPolicy? = StoreDefaults.memoryPolicy - private var validator: Validator? = null - - override fun scope(scope: CoroutineScope): MutableStoreBuilder { - this.scope = scope - return this - } - - override fun cachePolicy(memoryPolicy: MemoryPolicy?): MutableStoreBuilder { - cachePolicy = memoryPolicy - return this - } - - override fun disableCache(): MutableStoreBuilder { - cachePolicy = null - return this - } - - override fun validator(validator: Validator): MutableStoreBuilder { - this.validator = validator - return this - } - - fun build(): Store = - RealStore( - scope = scope ?: GlobalScope, - sourceOfTruth = sourceOfTruth, - fetcher = fetcher, - converter = converter, - validator = validator, - memCache = - memoryCache ?: cachePolicy?.let { - CacheBuilder().apply { - if (cachePolicy!!.hasAccessPolicy) { - expireAfterAccess(cachePolicy!!.expireAfterAccess) - } - if (cachePolicy!!.hasWritePolicy) { - expireAfterWrite(cachePolicy!!.expireAfterWrite) - } - if (cachePolicy!!.hasMaxSize) { - maximumSize(cachePolicy!!.maxSize) - } - - if (cachePolicy!!.hasMaxWeight) { - weigher(cachePolicy!!.maxWeight) { key, value -> - cachePolicy!!.weigher.weigh( - key, - value, - ) - } - } - }.build() - }, - ) - - override fun build( - updater: Updater, - bookkeeper: Bookkeeper?, - ): MutableStore = - build().asMutableStore( - updater = updater, - bookkeeper = bookkeeper, - ) -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealSourceOfTruth.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealSourceOfTruth.kt deleted file mode 100644 index 2c50aa6b7..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealSourceOfTruth.kt +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.mobilenativefoundation.store.store5.impl - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import org.mobilenativefoundation.store.store5.SourceOfTruth - -internal class PersistentSourceOfTruth( - private val realReader: (Key) -> Flow, - private val realWriter: suspend (Key, Local) -> Unit, - private val realDelete: (suspend (Key) -> Unit)? = null, - private val realDeleteAll: (suspend () -> Unit)? = null, -) : SourceOfTruth { - override fun reader(key: Key): Flow = realReader.invoke(key) - - override suspend fun write( - key: Key, - value: Local, - ) = realWriter(key, value) - - override suspend fun delete(key: Key) { - realDelete?.invoke(key) - } - - override suspend fun deleteAll() { - realDeleteAll?.invoke() - } -} - -internal class PersistentNonFlowingSourceOfTruth( - private val realReader: suspend (Key) -> Output?, - private val realWriter: suspend (Key, Local) -> Unit, - private val realDelete: (suspend (Key) -> Unit)? = null, - private val realDeleteAll: (suspend () -> Unit)?, -) : SourceOfTruth { - override fun reader(key: Key): Flow = - flow { - val sot = realReader(key) - emit(sot) - } - - override suspend fun write( - key: Key, - value: Local, - ) { - return realWriter(key, value) - } - - override suspend fun delete(key: Key) { - realDelete?.invoke(key) - } - - override suspend fun deleteAll() { - realDeleteAll?.invoke() - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStore.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStore.kt deleted file mode 100644 index 632f339c8..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStore.kt +++ /dev/null @@ -1,376 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.mobilenativefoundation.store.store5.impl - -import co.touchlab.kermit.CommonWriter -import co.touchlab.kermit.Logger -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.transform -import org.mobilenativefoundation.store.cache5.Cache -import org.mobilenativefoundation.store.core5.ExperimentalStoreApi -import org.mobilenativefoundation.store.store5.CacheType -import org.mobilenativefoundation.store.store5.Converter -import org.mobilenativefoundation.store.store5.Fetcher -import org.mobilenativefoundation.store.store5.SourceOfTruth -import org.mobilenativefoundation.store.store5.Store -import org.mobilenativefoundation.store.store5.StoreReadRequest -import org.mobilenativefoundation.store.store5.StoreReadResponse -import org.mobilenativefoundation.store.store5.StoreReadResponseOrigin -import org.mobilenativefoundation.store.store5.Validator -import org.mobilenativefoundation.store.store5.impl.operators.Either -import org.mobilenativefoundation.store.store5.impl.operators.merge -import org.mobilenativefoundation.store.store5.internal.result.StoreDelegateWriteResult - -internal class RealStore( - scope: CoroutineScope, - fetcher: Fetcher, - sourceOfTruth: SourceOfTruth? = null, - private val converter: Converter, - private val validator: Validator?, - private val memCache: Cache?, -) : Store { - /** - * This source of truth is either a real database or an in memory source of truth created by - * the builder. - * Whatever is given, we always put a [SourceOfTruthWithBarrier] in front of it so that while - * we write the value from fetcher into the disk, we can block reads to avoid sending new data - * as if it came from the server (the [StoreReadResponse.origin] field). - */ - private val sourceOfTruth: SourceOfTruthWithBarrier? = - sourceOfTruth?.let { - SourceOfTruthWithBarrier(it, converter) - } - - /** - * Fetcher controller maintains 1 and only 1 `Multicaster` for a given key to ensure network - * requests are shared. - */ - private val fetcherController = - FetcherController( - scope = scope, - realFetcher = fetcher, - sourceOfTruth = this.sourceOfTruth, - converter = converter, - ) - - @Suppress("UNCHECKED_CAST") - override fun stream(request: StoreReadRequest): Flow> = - flow { - val cachedToEmit = - if (request.shouldSkipCache(CacheType.MEMORY)) { - null - } else { - val output: Output? = memCache?.getIfPresent(request.key) - val isInvalid = output != null && validator?.isValid(output) == false - when { - output == null || isInvalid -> null - else -> output - } - } - - cachedToEmit?.let { it: Output -> - // if we read a value from cache, dispatch it first - emit(StoreReadResponse.Data(value = it, origin = StoreReadResponseOrigin.Cache)) - } - - if (sourceOfTruth == null && !request.fetch) { - if (memCache == null) { - logger.w("Local-only request made with no cache or source of truth configured") - } - emit(StoreReadResponse.NoNewData(origin = StoreReadResponseOrigin.Cache)) - return@flow - } - - val stream: Flow> = - if (sourceOfTruth == null) { - // piggyback only if not specified fresh data AND we emitted a value from the cache - val piggybackOnly = !request.refresh && cachedToEmit != null - @Suppress("UNCHECKED_CAST") - - createNetworkFlow( - request = request, - networkLock = null, - piggybackOnly = piggybackOnly, - ) as Flow> // when no source of truth Input == Output - } else if (request.fetch) { - diskNetworkCombined(request, sourceOfTruth) - } else { - val diskLock = CompletableDeferred() - diskLock.complete(Unit) - sourceOfTruth.reader(request.key, diskLock).transform { response -> - val data = response.dataOrNull() - if (data == null || validator?.isValid(data) == false) { - emit(StoreReadResponse.NoNewData(origin = response.origin)) - } else { - emit(StoreReadResponse.Data(value = data, origin = response.origin)) - } - } - } - emitAll( - stream.transform { output: StoreReadResponse -> - emit(output) - if (output is StoreReadResponse.NoNewData && cachedToEmit == null) { - // In the special case where fetcher returned no new data we actually want to - // serve cache data (even if the request specified skipping cache and/or SoT) - // - // For stream(Request.cached(key, refresh=true)) we will return: - // Cache - // Source of truth - // Fetcher - > Loading - // Fetcher - > NoNewData - // (future Source of truth updates) - // - // For stream(Request.fresh(key)) we will return: - // Fetcher - > Loading - // Fetcher - > NoNewData - // Cache - // Source of truth - // (future Source of truth updates) - memCache?.getIfPresent(request.key)?.let { - emit( - StoreReadResponse.Data( - value = it, - origin = StoreReadResponseOrigin.Cache, - ), - ) - } - } - }, - ) - }.onEach { - // whenever a value is dispatched, save it to the memory cache - if (it.origin != StoreReadResponseOrigin.Cache) { - it.dataOrNull()?.let { data -> - memCache?.put(request.key, data) - } - } - } - - override suspend fun clear(key: Key) { - memCache?.invalidate(key) - sourceOfTruth?.delete(key) - } - - @ExperimentalStoreApi - override suspend fun clear() { - memCache?.invalidateAll() - sourceOfTruth?.deleteAll() - } - - /** - * We want to stream from disk but also want to refresh. If requested or necessary. - * - * How it works: - * There are two flows: - * Fetcher: The flow we get for the fetching - * Disk: The flow we get from the [SourceOfTruth]. - * Both flows are controlled by a lock for each so that we can start the right one based on - * the request status or values we receive. - * - * Value is always returned from [SourceOfTruth] while the errors are dispatched from both the - * `Fetcher` and [SourceOfTruth]. - * - * There are two initialization paths: - * - * 1) Request wants to skip disk cache: - * In this case, we first start the fetcher flow. When fetcher flow provides something besides - * an error, we enable the disk flow. - * - * 2) Request does not want to skip disk cache: - * In this case, we first start the disk flow. If disk flow returns `null` or - * [StoreReadRequest.refresh] is set to `true`, we enable the fetcher flow. - * This ensures we first get the value from disk and then load from server if necessary. - */ - private fun diskNetworkCombined( - request: StoreReadRequest, - sourceOfTruth: SourceOfTruthWithBarrier, - ): Flow> { - val diskLock = CompletableDeferred() - val networkLock = CompletableDeferred() - val networkFlow = createNetworkFlow(request, networkLock) - val skipDiskCache = request.shouldSkipCache(CacheType.DISK) - if (!skipDiskCache) { - diskLock.complete(Unit) - } - val diskFlow = - sourceOfTruth.reader(request.key, diskLock).onStart { - // wait for disk to latch first to ensure it happens before network triggers. - // after that, if we'll not read from disk, then allow network to continue - if (skipDiskCache) { - networkLock.complete(Unit) - } - } - - val requestKeyToFetcherName: MutableMap = mutableMapOf() - // Track if network errored AND this is a fresh request where fallback behavior matters - var networkErrorWithNoFallback = false - // we use a merge implementation that gives the source of the flow so that we can decide - // based on that. - return networkFlow.merge(diskFlow).transform { - // left is Fetcher while right is source of truth - when (it) { - is Either.Left -> { - // left, that is data from network - val responseOrigin = it.value.origin as StoreReadResponseOrigin.Fetcher - requestKeyToFetcherName[request.key] = responseOrigin.name - - // Track if network errored and fallback to disk is disabled for fresh requests - if (it.value is StoreReadResponse.Error && skipDiskCache && !request.fallBackToSourceOfTruth) { - networkErrorWithNoFallback = true - } else if (it.value is StoreReadResponse.Data || it.value is StoreReadResponse.NoNewData) { - // Reset on success so subsequent SOT emissions aren't incorrectly filtered - networkErrorWithNoFallback = false - } - - if (it.value is StoreReadResponse.Data || - it.value is StoreReadResponse.NoNewData || - it.value is StoreReadResponse.Error - ) { - // Unlocking disk only if network sent data, reported no new data, or returned an error - // so that fresh data request never receives new fetcher data after - // cached disk data, and so that the flow can properly complete on errors. - // This means that if the user asked for fresh data but the network returned - // no new data we will still unblock disk. - diskLock.complete(Unit) - } - - if (it.value !is StoreReadResponse.Data) { - emit(it.value.swapType()) - } - } - - is Either.Right -> { - // right, that is data from disk - when (val diskData = it.value) { - is StoreReadResponse.Data -> { - // Skip disk data (SOT origin) if this was a fresh request that errored with fallback disabled. - // But always emit fresh network data (Fetcher origin) even after prior errors. - if (networkErrorWithNoFallback && diskData.origin !is StoreReadResponseOrigin.Fetcher) { - return@transform - } - - val responseOriginWithFetcherName = - diskData.origin.let { origin -> - if (origin is StoreReadResponseOrigin.Fetcher) { - origin.copy(name = requestKeyToFetcherName[request.key]) - } else { - origin - } - } - - val diskValue = diskData.value - val isValid = - (validator == null && diskValue != null) || - diskData.origin is StoreReadResponseOrigin.Fetcher || - (diskValue != null && validator?.isValid(diskValue) ?: true) - - if (isValid) { - @Suppress("UNCHECKED_CAST") - val output = - diskData.copy(origin = responseOriginWithFetcherName) as StoreReadResponse - emit(output) - } - // If the disk value is null - // or refresh was requested - // or the disk value is not valid - // then allow fetcher to start emitting values. - if (request.refresh || diskData.value == null || !isValid) { - networkLock.complete(Unit) - } - } - - is StoreReadResponse.Error -> { - // disk sent an error, send it down as well - emit(diskData) - - // If disk sent a read error, we should allow fetcher to start emitting - // values since there is nothing to read from disk. If disk sent a write - // error, we should NOT allow fetcher to start emitting values as we - // should always wait for the read attempt. - if (diskData is StoreReadResponse.Error.Exception && - diskData.error is SourceOfTruth.ReadException - ) { - networkLock.complete(Unit) - } - // for other errors, don't do anything, wait for the read attempt - } - - is StoreReadResponse.Initial, - is StoreReadResponse.Loading, - is StoreReadResponse.NoNewData, - -> { - } - } - } - } - } - } - - private fun createNetworkFlow( - request: StoreReadRequest, - networkLock: CompletableDeferred?, - piggybackOnly: Boolean = false, - ): Flow> { - return fetcherController - .getFetcher(request.key, piggybackOnly) - .onStart { - // wait until disk gives us the go - networkLock?.await() - if (!piggybackOnly) { - emit(StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher())) - } - } - } - - internal suspend fun write( - key: Key, - value: Output, - ): StoreDelegateWriteResult = - try { - val writeException = sourceOfTruth?.write(key, converter.fromOutputToLocal(value)) - if (writeException != null) { - StoreDelegateWriteResult.Error.Exception(writeException) - } else { - memCache?.put(key, value) - StoreDelegateWriteResult.Success - } - } catch (error: Throwable) { - StoreDelegateWriteResult.Error.Exception(error) - } - - internal suspend fun latestOrNull(key: Key): Output? = fromMemCache(key) ?: fromSourceOfTruth(key) - - private suspend fun fromSourceOfTruth(key: Key) = - sourceOfTruth?.reader(key, CompletableDeferred(Unit))?.map { it.dataOrNull() }?.first() - - private fun fromMemCache(key: Key) = memCache?.getIfPresent(key) - - companion object { - private val logger = - Logger.apply { - setLogWriters(listOf(CommonWriter())) - setTag("Store") - } - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStoreBuilder.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStoreBuilder.kt deleted file mode 100644 index 88b3d7667..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStoreBuilder.kt +++ /dev/null @@ -1,145 +0,0 @@ -@file:Suppress("UNCHECKED_CAST") - -package org.mobilenativefoundation.store.store5.impl - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import org.mobilenativefoundation.store.cache5.Cache -import org.mobilenativefoundation.store.cache5.CacheBuilder -import org.mobilenativefoundation.store.store5.Converter -import org.mobilenativefoundation.store.store5.Fetcher -import org.mobilenativefoundation.store.store5.MemoryPolicy -import org.mobilenativefoundation.store.store5.MutableStoreBuilder -import org.mobilenativefoundation.store.store5.SourceOfTruth -import org.mobilenativefoundation.store.store5.Store -import org.mobilenativefoundation.store.store5.StoreBuilder -import org.mobilenativefoundation.store.store5.StoreDefaults -import org.mobilenativefoundation.store.store5.Validator - -fun storeBuilderFromFetcher( - fetcher: Fetcher, - sourceOfTruth: SourceOfTruth? = null, -): StoreBuilder = RealStoreBuilder(fetcher, sourceOfTruth) - -fun storeBuilderFromFetcherAndSourceOfTruth( - fetcher: Fetcher, - sourceOfTruth: SourceOfTruth, -): StoreBuilder = RealStoreBuilder(fetcher, sourceOfTruth) - -fun storeBuilderFromFetcherSourceOfTruthAndMemoryCache( - fetcher: Fetcher, - sourceOfTruth: SourceOfTruth, - memoryCache: Cache, -): StoreBuilder = RealStoreBuilder(fetcher, sourceOfTruth, memoryCache) - -fun storeBuilderFromFetcherSourceOfTruthMemoryCacheAndConverter( - fetcher: Fetcher, - sourceOfTruth: SourceOfTruth, - memoryCache: Cache?, - converter: Converter, -): StoreBuilder = RealStoreBuilder(fetcher, sourceOfTruth, memoryCache, converter) - -internal class RealStoreBuilder( - private val fetcher: Fetcher, - private val sourceOfTruth: SourceOfTruth? = null, - private val memoryCache: Cache? = null, - private val converter: Converter? = null, -) : StoreBuilder { - private var scope: CoroutineScope? = null - private var cachePolicy: MemoryPolicy? = StoreDefaults.memoryPolicy - private var validator: Validator? = null - - override fun scope(scope: CoroutineScope): StoreBuilder { - this.scope = scope - return this - } - - override fun cachePolicy(memoryPolicy: MemoryPolicy?): StoreBuilder { - cachePolicy = memoryPolicy - return this - } - - override fun disableCache(): StoreBuilder { - cachePolicy = null - return this - } - - override fun validator(validator: Validator): StoreBuilder { - this.validator = validator - return this - } - - @OptIn(DelicateCoroutinesApi::class) - override fun build(): Store = - RealStore( - scope = scope ?: GlobalScope, - sourceOfTruth = sourceOfTruth, - fetcher = fetcher, - converter = converter ?: DefaultConverter(), - validator = validator, - memCache = - memoryCache ?: cachePolicy?.let { - CacheBuilder().apply { - if (cachePolicy!!.hasAccessPolicy) { - expireAfterAccess(cachePolicy!!.expireAfterAccess) - } - if (cachePolicy!!.hasWritePolicy) { - expireAfterWrite(cachePolicy!!.expireAfterWrite) - } - if (cachePolicy!!.hasMaxSize) { - maximumSize(cachePolicy!!.maxSize) - } - - if (cachePolicy!!.hasMaxWeight) { - weigher(cachePolicy!!.maxWeight) { key, value -> - cachePolicy!!.weigher.weigh( - key, - value, - ) - } - } - }.build() - }, - ) - - override fun toMutableStoreBuilder( - converter: Converter, - ): MutableStoreBuilder { - fetcher as Fetcher - return if (sourceOfTruth == null && memoryCache == null) { - mutableStoreBuilderFromFetcher(fetcher, converter) - } else if (memoryCache == null) { - mutableStoreBuilderFromFetcherAndSourceOfTruth( - fetcher, - sourceOfTruth as SourceOfTruth, - converter, - ) - } else { - mutableStoreBuilderFromFetcherSourceOfTruthAndMemoryCache( - fetcher, - sourceOfTruth as SourceOfTruth, - memoryCache, - converter, - ) - }.apply { - if (this@RealStoreBuilder.scope != null) { - scope(this@RealStoreBuilder.scope!!) - } - - if (this@RealStoreBuilder.cachePolicy != null) { - cachePolicy(this@RealStoreBuilder.cachePolicy) - } - - if (this@RealStoreBuilder.validator != null) { - validator(this@RealStoreBuilder.validator!!) - } - } - } -} - -private class DefaultConverter : Converter { - override fun fromOutputToLocal(output: Output): Local = throw IllegalStateException("non mutable store never call this function") - - override fun fromNetworkToLocal(network: Network): Local = network as Local -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStoreWriteRequest.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStoreWriteRequest.kt deleted file mode 100644 index bbf87fc08..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStoreWriteRequest.kt +++ /dev/null @@ -1,10 +0,0 @@ -package org.mobilenativefoundation.store.store5.impl - -import org.mobilenativefoundation.store.store5.StoreWriteRequest - -data class RealStoreWriteRequest( - override val key: Key, - override val value: Output, - override val created: Long, - override val onCompletions: List?, -) : StoreWriteRequest diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealValidator.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealValidator.kt deleted file mode 100644 index 4c956a924..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealValidator.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.mobilenativefoundation.store.store5.impl - -import org.mobilenativefoundation.store.store5.Validator - -internal class RealValidator( - private val realValidator: suspend (item: Output) -> Boolean, -) : Validator { - override suspend fun isValid(item: Output): Boolean = realValidator(item) -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RefCountedResource.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RefCountedResource.kt deleted file mode 100644 index d98473292..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RefCountedResource.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.mobilenativefoundation.store.store5.impl - -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock - -/** - * Simple holder that can ref-count items by a given key. - */ -internal class RefCountedResource( - private val create: suspend (Key) -> T, - private val onRelease: (suspend (Key, T) -> Unit)? = null, -) { - private val items = mutableMapOf() - private val lock = Mutex() - - suspend fun acquire(key: Key): T = - lock.withLock { - items.getOrPut(key) { - Item(create(key)) - }.also { - it.refCount++ - }.value - } - - suspend fun release( - key: Key, - value: T, - ) = lock.withLock { - val existing = items[key] - check(existing != null && existing.value === value) { - "inconsistent release, seems like $value was leaked or never acquired" - } - existing.refCount-- - if (existing.refCount < 1) { - items.remove(key) - onRelease?.invoke(key, value) - } - } - - // used in tests - suspend fun size() = - lock.withLock { - items.size - } - - private inner class Item( - val value: T, - var refCount: Int = 0, - ) -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/SourceOfTruthWithBarrier.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/SourceOfTruthWithBarrier.kt deleted file mode 100644 index d791757a7..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/SourceOfTruthWithBarrier.kt +++ /dev/null @@ -1,226 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.mobilenativefoundation.store.store5.impl - -import kotlinx.atomicfu.atomic -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.onStart -import org.mobilenativefoundation.store.store5.Converter -import org.mobilenativefoundation.store.store5.SourceOfTruth -import org.mobilenativefoundation.store.store5.StoreReadResponse -import org.mobilenativefoundation.store.store5.StoreReadResponseOrigin -import org.mobilenativefoundation.store.store5.impl.operators.mapIndexed - -/** - * Wraps a [SourceOfTruth] and blocks reads while a write is in progress. - * - * Used in the [RealStore] implementation to avoid - * dispatching values to downstream while a write is in progress. - */ -@Suppress("UNCHECKED_CAST") -internal class SourceOfTruthWithBarrier( - private val delegate: SourceOfTruth, - private val converter: Converter? = null, -) { - /** - * Each key has a barrier so that we can block reads while writing. - */ - private val barriers = - RefCountedResource>( - create = { - MutableStateFlow(BarrierMsg.Open.INITIAL) - }, - ) - - /** - * Each message gets dispatched with a version. This ensures we won't accidentally turn on the - * reader flow for a new reader that happens to have arrived while a write is in progress since - * that write should be considered as a disk read for that flow, not fetcher. - */ - private val versionCounter = atomic(0L) - - fun reader( - key: Key, - lock: CompletableDeferred, - ): Flow> { - return flow { - val barrier = barriers.acquire(key) - val readerVersion: Long = versionCounter.incrementAndGet() - try { - lock.await() - emitAll( - barrier - .flatMapLatest { barrierMessage -> - val messageArrivedAfterMe = readerVersion < barrierMessage.version - val writeError = - if (messageArrivedAfterMe && barrierMessage is BarrierMsg.Open) { - barrierMessage.writeError - } else { - null - } - val readFlow: Flow> = - when (barrierMessage) { - is BarrierMsg.Open -> - delegate.reader(key).mapIndexed { index, local: Output? -> - if (index == 0 && messageArrivedAfterMe) { - val firstMsgOrigin = - if (writeError == null) { - // restarted barrier without an error means write succeeded - StoreReadResponseOrigin.Fetcher() - } else { - // when a write fails, we still get a new reader because - // we've disabled the previous reader before starting the - // write operation. But since write has failed, we should - // use the SourceOfTruth as the origin - StoreReadResponseOrigin.SourceOfTruth - } - StoreReadResponse.Data( - origin = firstMsgOrigin, - value = local, - ) - } else { - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.SourceOfTruth, - value = local, - ) as StoreReadResponse - } - }.catch { throwable -> - this.emit( - StoreReadResponse.Error.Exception( - error = - SourceOfTruth.ReadException( - key = key, - cause = throwable.cause ?: throwable, - ), - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - ) - } - - is BarrierMsg.Blocked -> { - flowOf() - } - } - readFlow - .onStart { - // if we have a pending error, make sure to dispatch it first. - if (writeError != null) { - emit( - StoreReadResponse.Error.Exception( - origin = StoreReadResponseOrigin.SourceOfTruth, - error = writeError, - ), - ) - } - } - }, - ) - } finally { - // we are using a finally here instead of onCompletion as there might be a - // possibility where flow gets cancelled right before `emitAll`. - barriers.release(key, barrier) - } - } - } - - /** - * Writes a value to the underlying [SourceOfTruth] and returns any error that occurred. - * - * @return The [SourceOfTruth.WriteException] if the write failed, or null if successful. - * Callers like [RealStore.write] can check this to determine if the write succeeded. - * The barrier mechanism also notifies readers of the error via [BarrierMsg.Open.writeError]. - */ - @Suppress("UNCHECKED_CAST") - suspend fun write( - key: Key, - value: Local, - ): SourceOfTruth.WriteException? { - val barrier = barriers.acquire(key) - try { - barrier.emit(BarrierMsg.Blocked(versionCounter.incrementAndGet())) - val writeError = - try { - delegate.write(key, value) - null - } catch (throwable: Throwable) { - if (throwable !is CancellationException) { - throwable - } else { - null - } - } - - // Avoid double-wrapping if the error is already a WriteException. - val writeException = - writeError?.let { - writeError as? SourceOfTruth.WriteException - ?: SourceOfTruth.WriteException( - key = key, - value = value, - cause = writeError, - ) - } - - barrier.emit( - BarrierMsg.Open( - version = versionCounter.incrementAndGet(), - writeError = writeException, - ), - ) - - // Return the error so callers know the operation failed. - // The barrier message above notifies readers of the error. - return writeException - } finally { - barriers.release(key, barrier) - } - } - - suspend fun delete(key: Key) { - delegate.delete(key) - } - - suspend fun deleteAll() { - delegate.deleteAll() - } - - private sealed class BarrierMsg( - val version: Long, - ) { - class Blocked(version: Long) : BarrierMsg(version) - - class Open(version: Long, val writeError: Throwable? = null) : BarrierMsg(version) { - companion object { - val INITIAL = Open(INITIAL_VERSION) - } - } - } - - // visible for testing - internal suspend fun barrierCount() = barriers.size() - - companion object { - private const val INITIAL_VERSION = -1L - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.kt deleted file mode 100644 index c2d33d4fb..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.mobilenativefoundation.store.store5.impl.extensions - -import kotlin.time.Duration.Companion.hours - -internal expect fun currentTimeMillis(): Long - -internal fun now() = currentTimeMillis() - -internal fun inHours(n: Int) = currentTimeMillis() + n.hours.inWholeMilliseconds diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/store.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/store.kt deleted file mode 100644 index cdf0910c7..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/store.kt +++ /dev/null @@ -1,156 +0,0 @@ -package org.mobilenativefoundation.store.store5.impl.extensions - -import kotlinx.coroutines.flow.filterNot -import kotlinx.coroutines.flow.first -import org.mobilenativefoundation.store.core5.ExperimentalStoreApi -import org.mobilenativefoundation.store.store5.Bookkeeper -import org.mobilenativefoundation.store.store5.MutableStore -import org.mobilenativefoundation.store.store5.Store -import org.mobilenativefoundation.store.store5.StoreReadRequest -import org.mobilenativefoundation.store.store5.StoreReadResponse -import org.mobilenativefoundation.store.store5.Updater -import org.mobilenativefoundation.store.store5.impl.RealMutableStore -import org.mobilenativefoundation.store.store5.impl.RealStore - -/** - * Helper factory that will return data for [key] if it is cached otherwise will return - * fresh/network data (updating your caches) - * - * Note: Exceptions will not be handled within this function. - * - * ``` - * try { - * store.get(Key.Read.All) - * } catch (e: Exception) { - * // handle exception - * } - * ``` - * - * @param Key The key to get cached data for. - * @param Output The common representation of the data. - */ -suspend fun Store.get(key: Key) = - stream(StoreReadRequest.cached(key, refresh = false)) - .filterNot { it is StoreReadResponse.Loading || it is StoreReadResponse.NoNewData } - .first() - .requireData() - -/** - * Helper factory that will return fresh data for [key] while updating your caches - * - * If the [Fetcher] does not return any data (i.e the returned - * [kotlinx.coroutines.flow.Flow], when collected, is empty). Then store will fall back to local - * data **even** if you explicitly requested fresh data. - * See https://github.com/dropbox/Store/pull/194 for context - * - * Note: Exceptions will not be handled within this function. - * - * ``` - * try { - * store.fresh(Key.Read.All) - * } catch (e: Exception) { - * // handle exception - * } - * ``` - * - * @param Key The key to fetch fresh data for. - * @param Output The common representation of the data. - * @return The fresh data associated with the key. - */ -suspend fun Store.fresh(key: Key) = - stream(StoreReadRequest.fresh(key)) - .filterNot { it is StoreReadResponse.Loading || it is StoreReadResponse.NoNewData } - .first() - .requireData() - -/** - * Extension function to convert a [Store] into a [MutableStore]. - * - * This function allows a [Store] to be used as a [MutableStore] by providing an [Updater] and an - * optional [Bookkeeper]. - * - * ``` - * store.asMutableStore(updater, bookkeeper) - * ``` - * - * @param Key The type of the key used to get data. - * @param Network The type of data returned by the fetcher - * @param Output The common representation of the data. - * @param Local The type of the data used by the source of truth. - * @param Response The updater result write response type. - * @param updater Posts data to remote data source. - * @param bookkeeper Optionally used to track when local changes fail to sync with network. - * @return A [MutableStore] instance. - * @throws Exception if the [Store] is not built using [StoreBuilder]. - */ -@OptIn(ExperimentalStoreApi::class) -@Suppress("UNCHECKED_CAST") -fun Store.asMutableStore( - updater: Updater, - bookkeeper: Bookkeeper?, -): MutableStore { - val delegate = - this as? RealStore - ?: throw Exception("MutableStore requires Store to be built using StoreBuilder") - - return RealMutableStore( - delegate = delegate, - updater = updater, - bookkeeper = bookkeeper, - ) -} - -/** - * Helper function that returns data for the given [key] if it is cached, otherwise it will return - * fresh/network data (updating your caches). - * - * Note: Exceptions will not be handled within this function. - * - * ``` - * try { - * store.get(Key.Read.All) - * } catch (e: Exception) { - * // handle exception - * } - * ``` - * - * @param Key The key to get cached data for. - * @param Output The common representation of the data. - * @param Response The updater result write response type. - * @return The data associated with the key. - */ -@OptIn(ExperimentalStoreApi::class) -suspend fun MutableStore.get(key: Key) = - stream(StoreReadRequest.cached(key, refresh = false)) - .filterNot { it is StoreReadResponse.Loading || it is StoreReadResponse.NoNewData } - .first() - .requireData() - -/** - * Helper function that returns fresh data for the given [key] while updating your caches. - * - * If the [Fetcher] does not return any data (i.e., the returned [kotlinx.coroutines.flow.Flow], - * when collected, is empty), then the store will fall back to local data even if you explicitly - * requested fresh data. - * - * Note: Exceptions will not be handled within this function. - * - * ``` - * try { - * store.fresh(Key.Read.All) - * } catch (e: Exception) { - * // handle exception - * } - * ``` - * - * @param Key The key to fetch fresh data for. - * @param Output The common representation of the data. - * @param Response The updater result write response type. - * @return The fresh data associated with the key. - */ -@OptIn(ExperimentalStoreApi::class) -suspend fun MutableStore.fresh(key: Key) = - stream(StoreReadRequest.fresh(key)) - .filterNot { it is StoreReadResponse.Loading || it is StoreReadResponse.NoNewData } - .first() - .requireData() diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/operators/FlowMerge.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/operators/FlowMerge.kt deleted file mode 100644 index 09d4f4cf9..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/operators/FlowMerge.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.mobilenativefoundation.store.store5.impl.operators - -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.buffer -import kotlinx.coroutines.flow.channelFlow -import kotlinx.coroutines.launch - -/** - * Merge implementation tells downstream what the source is and also uses a rendezvous channel - */ -internal fun Flow.merge(other: Flow): Flow> { - return channelFlow> { - launch { - this@merge.collect { - send(Either.Left(it)) - } - } - launch { - other.collect { - send(Either.Right(it)) - } - } - }.buffer(Channel.RENDEZVOUS) -} - -internal sealed class Either { - data class Left(val value: T) : Either() - - data class Right(val value: R) : Either() -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/operators/MapIndexed.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/operators/MapIndexed.kt deleted file mode 100644 index c3644cd0c..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/operators/MapIndexed.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.mobilenativefoundation.store.store5.impl.operators - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collectIndexed -import kotlinx.coroutines.flow.flow - -internal inline fun Flow.mapIndexed(crossinline block: (Int, T) -> R) = - flow { - collectIndexed { index, value -> - emit(block(index, value)) - } - } diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/concurrent/ThreadSafety.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/concurrent/ThreadSafety.kt deleted file mode 100644 index 28e04a706..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/concurrent/ThreadSafety.kt +++ /dev/null @@ -1,12 +0,0 @@ -package org.mobilenativefoundation.store.store5.internal.concurrent - -import kotlinx.coroutines.sync.Mutex - -internal data class ThreadSafety( - val writeRequests: StoreThreadSafety = StoreThreadSafety(), - val readCompletions: StoreThreadSafety = StoreThreadSafety(), -) - -internal data class StoreThreadSafety( - val mutex: Mutex = Mutex(), -) diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/definition/Timestamp.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/definition/Timestamp.kt deleted file mode 100644 index 82019ad80..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/definition/Timestamp.kt +++ /dev/null @@ -1,3 +0,0 @@ -package org.mobilenativefoundation.store.store5.internal.definition - -typealias Timestamp = Long diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/definition/WriteRequestQueue.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/definition/WriteRequestQueue.kt deleted file mode 100644 index eef08167e..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/definition/WriteRequestQueue.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.mobilenativefoundation.store.store5.internal.definition - -import org.mobilenativefoundation.store.store5.StoreWriteRequest - -typealias WriteRequestQueue = ArrayDeque> diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult.kt deleted file mode 100644 index 3f49c674e..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult.kt +++ /dev/null @@ -1,17 +0,0 @@ -package org.mobilenativefoundation.store.store5.internal.result - -import org.mobilenativefoundation.store.store5.UpdaterResult - -sealed class EagerConflictResolutionResult { - sealed class Success : EagerConflictResolutionResult() { - object NoConflicts : Success() - - data class ConflictsResolved(val value: UpdaterResult.Success) : Success() - } - - sealed class Error : EagerConflictResolutionResult() { - data class Message(val message: String) : Error() - - data class Exception(val error: Throwable) : Error() - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult.kt deleted file mode 100644 index 3a760e750..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.mobilenativefoundation.store.store5.internal.result - -sealed class StoreDelegateWriteResult { - object Success : StoreDelegateWriteResult() - - sealed class Error : StoreDelegateWriteResult() { - data class Message(val error: String) : Error() - - data class Exception(val error: Throwable) : Error() - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/storeBuilder.uml b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/storeBuilder.uml deleted file mode 100644 index 87f1d7f4b..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/storeBuilder.uml +++ /dev/null @@ -1,19 +0,0 @@ - - - JAVA - - - - - - - - Fields - Constructors - Methods - Properties - Inner Classes - - All - public - \ No newline at end of file diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/ClearAllStoreTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/ClearAllStoreTests.kt deleted file mode 100644 index a871a0665..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/ClearAllStoreTests.kt +++ /dev/null @@ -1,180 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.mobilenativefoundation.store.core5.ExperimentalStoreApi -import org.mobilenativefoundation.store.store5.util.InMemoryPersister -import org.mobilenativefoundation.store.store5.util.asSourceOfTruth -import org.mobilenativefoundation.store.store5.util.getData -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull - -@FlowPreview -@ExperimentalCoroutinesApi -@ExperimentalStoreApi -class ClearAllStoreTests { - private val testScope = TestScope() - - private val key1 = "key1" - private val key2 = "key2" - private val value1 = 1 - private val value2 = 2 - - private lateinit var fetcher: Fetcher - - private lateinit var persister: InMemoryPersister - - @BeforeTest - fun before() { - persister = InMemoryPersister() - fetcher = - Fetcher.of { key: String -> - when (key) { - key1 -> value1 - key2 -> value2 - else -> throw IllegalStateException("Unknown key") - } - } - } - - @Test - fun callingClearAllOnStoreWithPersisterAndNoInMemoryCacheDeletesAllEntriesFromThePersister() = - testScope.runTest { - val store = - StoreBuilder.from( - fetcher = fetcher, - sourceOfTruth = persister.asSourceOfTruth(), - ).scope(testScope) - .disableCache() - .build() - - // should receive data from network first time - val responseOneA = store.getData(key1) - advanceUntilIdle() - assertEquals( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value1, - ), - responseOneA, - ) - val responseTwoA = store.getData(key2) - advanceUntilIdle() - assertEquals( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value2, - ), - responseTwoA, - ) - // should receive data from persister - val responseOneB = store.getData(key1) - advanceUntilIdle() - assertEquals( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.SourceOfTruth, - value = value1, - ), - responseOneB, - ) - val responseTwoB = store.getData(key2) - advanceUntilIdle() - assertEquals( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.SourceOfTruth, - value = value2, - ), - responseTwoB, - ) - // clear all entries in store - store.clear() - assertNull(persister.peekEntry(key1)) - assertNull(persister.peekEntry(key2)) - - // should fetch data from network again - val responseOneC = store.getData(key1) - advanceUntilIdle() - assertEquals( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value1, - ), - responseOneC, - ) - - val responseTwoC = store.getData(key2) - advanceUntilIdle() - assertEquals( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value2, - ), - responseTwoC, - ) - } - - @Test - fun callingClearAllOnStoreWithInMemoryCacheAndNoPersisterDeletesAllEntriesFromTheInMemoryCache() = - testScope.runTest { - val store = - StoreBuilder.from( - fetcher = fetcher, - ).scope(testScope).build() - - // should receive data from network first time - assertEquals( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value1, - ), - store.getData(key1), - ) - assertEquals( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value2, - ), - store.getData(key2), - ) - - // should receive data from cache - assertEquals( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Cache, - value = value1, - ), - store.getData(key1), - ) - assertEquals( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Cache, - value = value2, - ), - store.getData(key2), - ) - - // clear all entries in store - store.clear() - - // should fetch data from network again - assertEquals( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value1, - ), - store.getData(key1), - ) - assertEquals( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value2, - ), - store.getData(key2), - ) - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/ClearStoreByKeyTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/ClearStoreByKeyTests.kt deleted file mode 100644 index e08556b44..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/ClearStoreByKeyTests.kt +++ /dev/null @@ -1,159 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.mobilenativefoundation.store.store5.StoreReadResponse.Data -import org.mobilenativefoundation.store.store5.util.InMemoryPersister -import org.mobilenativefoundation.store.store5.util.asSourceOfTruth -import org.mobilenativefoundation.store.store5.util.getData -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull - -@FlowPreview -@ExperimentalCoroutinesApi -class ClearStoreByKeyTests { - private val testScope = TestScope() - - private val persister = InMemoryPersister() - - @Test - fun callingClearWithKeyOnStoreWithPersisterWithNoInMemoryCacheDeletesTheEntryAssociatedWithTheKeyFromThePersister() = - testScope.runTest { - val key = "key" - val value = 1 - val store = - StoreBuilder.from( - fetcher = Fetcher.of { value }, - sourceOfTruth = persister.asSourceOfTruth(), - ).scope(testScope) - .disableCache() - .build() - - // should receive data from network first time - assertEquals( - Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value, - ), - store.getData(key), - ) - - // should receive data from persister - assertEquals( - Data( - origin = StoreReadResponseOrigin.SourceOfTruth, - value = value, - ), - store.getData(key), - ) - - // clear store entry by key - store.clear(key) - assertNull(persister.peekEntry(key)) - // should fetch data from network again - assertEquals( - Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value, - ), - store.getData(key), - ) - } - - @Test - fun callingClearWithKeyOStoreWithInMemoryCacheNoPersisterDeletesTheEntryAssociatedWithTheKeyFromTheInMemoryCache() = - testScope.runTest { - val key = "key" - val value = 1 - val store = - StoreBuilder.from( - fetcher = Fetcher.of { value }, - ).scope(testScope).build() - - // should receive data from network first time - assertEquals( - Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value, - ), - store.getData(key), - ) - - // should receive data from cache - assertEquals( - Data( - origin = StoreReadResponseOrigin.Cache, - value = value, - ), - store.getData(key), - ) - - // clear store entry by key - store.clear(key) - - // should fetch data from network again - assertEquals( - Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value, - ), - store.getData(key), - ) - } - - @Test - fun callingClearWithKeyOnStoreHasNoEffectOnExistingEntriesAssociatedWithOtherKeysInTheInMemoryCacheOrPersister() = - testScope.runTest { - val key1 = "key1" - val key2 = "key2" - val value1 = 1 - val value2 = 2 - val store = - StoreBuilder.from( - fetcher = - Fetcher.of { key -> - when (key) { - key1 -> value1 - key2 -> value2 - else -> throw IllegalStateException("Unknown key") - } - }, - sourceOfTruth = persister.asSourceOfTruth(), - ).scope(testScope) - .build() - - // get data for both keys - store.getData(key1) - store.getData(key2) - - // clear store entry for key1 - store.clear(key1) - - // entry for key1 is gone - assertNull(persister.peekEntry(key1)) - - // entry for key2 should still exists - assertEquals(value2, persister.peekEntry(key2)) - - // getting data for key1 should hit the network again - assertEquals( - Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value1, - ), - store.getData(key1), - ) - - // getting data for key2 should not hit the network - assertEquals( - Data( - origin = StoreReadResponseOrigin.Cache, - value = value2, - ), - store.getData(key2), - ) - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FallbackTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FallbackTests.kt deleted file mode 100644 index d18cc79db..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FallbackTests.kt +++ /dev/null @@ -1,149 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import kotlinx.coroutines.flow.take -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.mobilenativefoundation.store.store5.util.fake.fallback.HardcodedPages -import org.mobilenativefoundation.store.store5.util.fake.fallback.Page -import org.mobilenativefoundation.store.store5.util.fake.fallback.PagesDatabase -import org.mobilenativefoundation.store.store5.util.fake.fallback.PrimaryPagesApi -import org.mobilenativefoundation.store.store5.util.fake.fallback.SecondaryPagesApi -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals - -class FallbackTests { - private val testScope = TestScope() - private lateinit var api: PrimaryPagesApi - private lateinit var secondaryApi: SecondaryPagesApi - private lateinit var hardcodedPages: HardcodedPages - private lateinit var pagesDatabase: PagesDatabase - - @BeforeTest - fun before() { - api = PrimaryPagesApi() - secondaryApi = SecondaryPagesApi() - hardcodedPages = HardcodedPages() - pagesDatabase = PagesDatabase() - } - - @Test - fun givenEmptyStoreWhenSuccessFromPrimaryApiThenStoreReadResponseOfPrimaryApiResult() = - testScope.runTest { - val ttl = null - val fail = false - - val hardcodedPagesFetcher = Fetcher.of { key -> hardcodedPages.get(key) } - val secondaryApiFetcher = - Fetcher.withFallback( - secondaryApi.name, - hardcodedPagesFetcher, - ) { key -> secondaryApi.get(key) } - - val store = - StoreBuilder.from( - fetcher = Fetcher.withFallback(api.name, secondaryApiFetcher) { key -> api.fetch(key, fail, ttl) }, - sourceOfTruth = - SourceOfTruth.of( - nonFlowReader = { key -> pagesDatabase.get(key) }, - writer = { key, page -> pagesDatabase.put(key, page) }, - delete = null, - deleteAll = null, - ), - ).build() - - val responses = store.stream(StoreReadRequest.fresh("1")).take(2).toList() - - assertEquals( - listOf( - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data(Page.Data("1", null), StoreReadResponseOrigin.Fetcher(api.name)), - ), - responses, - ) - } - - @Test - fun givenEmptyStoreWhenFailureFromPrimaryApiThenStoreReadResponseOfSecondaryApiResult() = - testScope.runTest { - val ttl = null - val fail = true - - val hardcodedPagesFetcher = Fetcher.of { key -> hardcodedPages.get(key) } - val secondaryApiFetcher = - Fetcher.withFallback( - secondaryApi.name, - hardcodedPagesFetcher, - ) { key -> secondaryApi.get(key) } - - val store = - StoreBuilder.from( - fetcher = Fetcher.withFallback(api.name, secondaryApiFetcher) { key -> api.fetch(key, fail, ttl) }, - sourceOfTruth = - SourceOfTruth.of( - nonFlowReader = { key -> pagesDatabase.get(key) }, - writer = { key, page -> pagesDatabase.put(key, page) }, - delete = null, - deleteAll = null, - ), - ).build() - - val responses = store.stream(StoreReadRequest.fresh("1")).take(2).toList() - - assertEquals( - listOf( - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data( - Page.Data("1", null), - StoreReadResponseOrigin.Fetcher(secondaryApiFetcher.name), - ), - ), - responses, - ) - } - - @Test - fun givenEmptyStoreWhenFailureFromPrimaryAndSecondaryApisThenStoreReadResponseOfHardcodedData() = - - testScope.runTest { - val ttl = null - val fail = true - - val hardcodedPagesFetcher = Fetcher.of { key -> hardcodedPages.get(key) } - val throwingSecondaryApiFetcher = - Fetcher.withFallback(secondaryApi.name, hardcodedPagesFetcher) { throw Exception() } - - val store = - StoreBuilder.from( - fetcher = - Fetcher.withFallback(api.name, throwingSecondaryApiFetcher) { key -> - api.fetch( - key, - fail, - ttl, - ) - }, - sourceOfTruth = - SourceOfTruth.of( - nonFlowReader = { key -> pagesDatabase.get(key) }, - writer = { key, page -> pagesDatabase.put(key, page) }, - delete = null, - deleteAll = null, - ), - ).build() - - val responses = store.stream(StoreReadRequest.fresh("1")).take(2).toList() - - assertEquals( - listOf( - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data( - Page.Data("1", null), - StoreReadResponseOrigin.Fetcher(hardcodedPagesFetcher.name), - ), - ), - responses, - ) - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FetcherControllerTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FetcherControllerTests.kt deleted file mode 100644 index e8ef19509..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FetcherControllerTests.kt +++ /dev/null @@ -1,141 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.async -import kotlinx.coroutines.cancelChildren -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.mobilenativefoundation.store.store5.StoreReadResponse.Data -import org.mobilenativefoundation.store.store5.impl.FetcherController -import kotlin.test.Test -import kotlin.test.assertEquals - -@ExperimentalCoroutinesApi -@FlowPreview -class FetcherControllerTests { - private val testScope = TestScope() - - @Test - fun simple() = - testScope.runTest { - val fetcherController = - FetcherController( - scope = testScope, - realFetcher = - Fetcher.ofResultFlow { key: Int -> - flow { - emit(FetcherResult.Data(key * key) as FetcherResult) - } - }, - sourceOfTruth = null, - ) - val fetcher = fetcherController.getFetcher(3) - assertEquals(0, fetcherController.fetcherSize()) - val received = - fetcher.onEach { - assertEquals(1, fetcherController.fetcherSize()) - }.first() - assertEquals( - Data( - value = 9, - origin = StoreReadResponseOrigin.Fetcher(), - ), - received, - ) - assertEquals(0, fetcherController.fetcherSize()) - } - - @Test - fun concurrent() = - testScope.runTest { - var createdCnt = 0 - val fetcherController = - FetcherController( - scope = testScope, - realFetcher = - Fetcher.ofResultFlow { key: Int -> - createdCnt++ - flow { - // make sure it takes time, otherwise, we may not share - delay(1) - emit(FetcherResult.Data(key * key) as FetcherResult) - } - }, - sourceOfTruth = null, - ) - val fetcherCount = 20 - - fun createFetcher() = - async { - fetcherController.getFetcher(3) - .onEach { - assertEquals(1, fetcherController.fetcherSize()) - }.first() - } - - val fetchers = - (0 until fetcherCount).map { - createFetcher() - } - fetchers.forEach { - assertEquals( - Data( - value = 9, - origin = StoreReadResponseOrigin.Fetcher(), - ), - it.await(), - ) - } - assertEquals(0, fetcherController.fetcherSize()) - assertEquals(1, createdCnt) - } - - @Test - fun concurrent_when_cancelled() = - testScope.runTest { - var createdCnt = 0 - val job = SupervisorJob() - val scope = TestScope(StandardTestDispatcher() + job) - val fetcherController = - FetcherController( - scope = scope, - realFetcher = - Fetcher.ofResultFlow { key: Int -> - createdCnt++ - flow { - // make sure it takes time, otherwise, we may not share - advanceUntilIdle() - emit(FetcherResult.Data(key * key) as FetcherResult) - } - }, - sourceOfTruth = null, - ) - val fetcherCount = 20 - - fun createFetcher() = - scope.launch { - fetcherController.getFetcher(3) - .onEach { - assertEquals(1, fetcherController.fetcherSize()) - }.first() - } - - (0 until fetcherCount).map { - createFetcher() - } - scope.advanceUntilIdle() - job.cancelChildren() - scope.advanceUntilIdle() - assertEquals(0, fetcherController.fetcherSize()) - assertEquals(1, createdCnt) - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FetcherResponseTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FetcherResponseTests.kt deleted file mode 100644 index bc1ad5037..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FetcherResponseTests.kt +++ /dev/null @@ -1,300 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import app.cash.turbine.test -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith - -@ExperimentalCoroutinesApi -@FlowPreview -class FetcherResponseTests { - private val testScope = TestScope() - - @Test - fun givenAFetcherThatThrowsAnExceptionInInvokeWhenStreamingThenTheExceptionsShouldNotBeCaught() = - testScope.runTest { - val store = - StoreBuilder.from( - Fetcher.ofResult { - throw RuntimeException("don't catch me") - }, - ).buildWithTestScope() - - assertFailsWith(message = "don't catch me") { - val result = store.stream(StoreReadRequest.fresh(1)).toList() - assertEquals(0, result.size) - } - } - - @Test - fun givenAFetcherThatEmitsErrorAndDataWhenSteamingThenItCanEmitValueAfterAnError() = - testScope.runTest { - val exception = RuntimeException("first error") - val store = - StoreBuilder.from( - fetcher = - Fetcher.ofResultFlow { key: Int -> - flowOf( - FetcherResult.Error.Exception(exception), - FetcherResult.Data("$key"), - ) - }, - ).buildWithTestScope() - - store.stream(StoreReadRequest.fresh(1)).test { - assertEquals( - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Error.Exception(exception, StoreReadResponseOrigin.Fetcher()), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Data("1", StoreReadResponseOrigin.Fetcher()), - awaitItem(), - ) - } - } - - @Test - fun givenTransformerWhenRawValueThenUnwrappedValueReturnedAndValueIsCached() = - testScope.runTest { - val fetcher = Fetcher.ofFlow { flowOf(it * it) } - val pipeline = - StoreBuilder - .from(fetcher).buildWithTestScope() - - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Data( - value = 9, - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { - assertEquals( - StoreReadResponse.Data( - value = 9, - origin = StoreReadResponseOrigin.Cache, - ), - awaitItem(), - ) - } - } - - @Test - fun givenTransformerWhenErrorMessageThenErrorReturnedToUserAndErrorIsNotCached() = - testScope.runTest { - var count = 0 - val fetcher = - Fetcher.ofResultFlow { _: Int -> - flowOf(count++).map { - if (it > 0) { - FetcherResult.Data(it) - } else { - FetcherResult.Error.Message("zero") - } - } - } - val pipeline = - StoreBuilder.from(fetcher) - .buildWithTestScope() - - pipeline.stream(StoreReadRequest.fresh(3)).test { - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Error.Message( - message = "zero", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Data( - value = 1, - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } - - @Test - fun givenTransformerWhenErrorExceptionThenErrorReturnedToUserAndErrorIsNotCached() = - testScope.runTest { - val e = Exception() - var count = 0 - val fetcher = - Fetcher.ofResultFlow { _: Int -> - flowOf(count++).map { - if (it > 0) { - FetcherResult.Data(it) - } else { - FetcherResult.Error.Exception(e) - } - } - } - val pipeline = - StoreBuilder - .from(fetcher) - .buildWithTestScope() - - pipeline.stream(StoreReadRequest.fresh(3)).test { - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Error.Exception( - error = e, - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Data( - value = 1, - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } - - @Test - fun givenExceptionsAsErrorsWhenExceptionThrownThenErrorReturnedToUserAndErrorIsNotCached() = - testScope.runTest { - var count = 0 - val e = Exception() - val fetcher = - Fetcher.of { - count++ - if (count == 1) { - throw e - } - count - 1 - } - val pipeline = - StoreBuilder - .from(fetcher = fetcher) - .buildWithTestScope() - - pipeline.stream(StoreReadRequest.fresh(3)).test { - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Error.Exception( - error = e, - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Data( - value = 1, - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } - - @Test - fun givenAFetcherThatEmitsCustomErrorWhenStreamingThenCustomErrorShouldBeEmitted() = - testScope.runTest { - data class TestCustomError(val errorMessage: String) - - val customError = TestCustomError("Test custom error") - - val store = - StoreBuilder.from( - fetcher = - Fetcher.ofResultFlow { _: Int -> - flowOf( - FetcherResult.Error.Custom(customError), - ) - }, - ).buildWithTestScope() - - store.stream(StoreReadRequest.fresh(1)).test { - assertEquals( - StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher()), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Error.Custom( - error = customError, - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } - - private fun StoreBuilder.buildWithTestScope() = scope(testScope).build() -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FlowStoreTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FlowStoreTests.kt deleted file mode 100644 index aacbbda83..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FlowStoreTests.kt +++ /dev/null @@ -1,1341 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.mobilenativefoundation.store.store5 - -import app.cash.turbine.test -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.async -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.take -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import org.mobilenativefoundation.store.store5.StoreReadResponse.Data -import org.mobilenativefoundation.store.store5.StoreReadResponse.Loading -import org.mobilenativefoundation.store.store5.impl.extensions.fresh -import org.mobilenativefoundation.store.store5.util.FakeFetcher -import org.mobilenativefoundation.store.store5.util.FakeFlowingFetcher -import org.mobilenativefoundation.store.store5.util.InMemoryPersister -import org.mobilenativefoundation.store.store5.util.asFlowable -import org.mobilenativefoundation.store.store5.util.asSourceOfTruth -import kotlin.test.Test -import kotlin.test.assertContains -import kotlin.test.assertEquals -import kotlin.test.assertIs - -@FlowPreview -@ExperimentalCoroutinesApi -class FlowStoreTests { - private val testScope = TestScope() - - @Test - fun getAndFresh() = - testScope.runTest { - val fetcher = - FakeFetcher( - 3 to "three-1", - 3 to "three-2", - ) - val pipeline = - StoreBuilder - .from(fetcher) - .buildWithTestScope() - - assertEquals( - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).take(2).toList(), - listOf( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - ), - ) - - assertEquals( - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).take(1).toList(), - listOf( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Cache, - ), - ), - ) - - assertEquals( - pipeline.stream(StoreReadRequest.fresh(3)).take(2).toList(), - listOf( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - ), - ) - - assertEquals( - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).take(1).toList(), - listOf( - Data( - value = "three-2", - origin = StoreReadResponseOrigin.Cache, - ), - ), - ) - } - - @Test - fun getAndFresh_withPersister() = - testScope.runTest { - val fetcher = - FakeFetcher( - 3 to "three-1", - 3 to "three-2", - ) - val persister = InMemoryPersister() - val pipeline = - StoreBuilder.from( - fetcher = fetcher, - sourceOfTruth = persister.asSourceOfTruth(), - ).buildWithTestScope() - - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Cache, - ), - awaitItem(), - ) - - // note that we still get the data from persister as well as we don't listen to - // the persister for the cached items unless there is an active stream, which - // means cache can go out of sync w/ the persister - - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.fresh(3)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { - assertEquals( - Data( - value = "three-2", - origin = StoreReadResponseOrigin.Cache, - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-2", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - } - } - - @Test - fun streamAndFresh_withPersister() = - testScope.runTest { - val fetcher = - FakeFetcher( - 3 to "three-1", - 3 to "three-2", - ) - val persister = InMemoryPersister() - - val pipeline = - StoreBuilder.from( - fetcher = fetcher, - sourceOfTruth = persister.asSourceOfTruth(), - ).buildWithTestScope() - - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Cache, - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } - - @Test - fun streamAndFresh() = - testScope.runTest { - val fetcher = - FakeFetcher( - 3 to "three-1", - 3 to "three-2", - ) - val pipeline = - StoreBuilder.from(fetcher = fetcher) - .buildWithTestScope() - - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Cache, - ), - awaitItem(), - ) - - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } - - @Test - fun skipCache() = - testScope.runTest { - val fetcher = - FakeFetcher( - 3 to "three-1", - 3 to "three-2", - ) - val pipeline = - StoreBuilder.from(fetcher = fetcher) - .buildWithTestScope() - - pipeline.stream(StoreReadRequest.skipMemory(3, refresh = false)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.skipMemory(3, refresh = false)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } - - @Test - fun flowingFetcher() = - testScope.runTest { - val fetcher = - FakeFlowingFetcher( - 3 to "three-1", - 3 to "three-2", - ) - val persister = InMemoryPersister() - - val pipeline = - StoreBuilder.from( - fetcher = fetcher, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .disableCache() - .buildWithTestScope() - - pipeline.stream(StoreReadRequest.fresh(3)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - Data( - value = "three-2", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } - - @Test - fun diskChangeWhileNetworkIsFlowing_simple() = - testScope.runTest { - val persister = InMemoryPersister().asFlowable() - val pipeline = - StoreBuilder.from( - Fetcher.ofFlow { - flow { - delay(20) - emit("three-1") - } - }, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .disableCache() - .buildWithTestScope() - - launch { - delay(10) - persister.flowWriter(3, "local-1") - } - - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "local-1", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } - - @Test - fun diskChangeWhileNetworkIsFlowing_overwrite() = - testScope.runTest { - val persister = InMemoryPersister().asFlowable() - val pipeline = - StoreBuilder.from( - fetcher = - Fetcher.ofFlow { - flow { - delay(10) - emit("three-1") - delay(10) - emit("three-2") - } - }, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .disableCache() - .buildWithTestScope() - - launch { - delay(5) - persister.flowWriter(3, "local-1") - delay(10) // go in between two server requests - persister.flowWriter(3, "local-2") - } - - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "local-1", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "local-2", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } - - @Test - fun errorTest() = - testScope.runTest { - val exception = IllegalArgumentException("wow") - val persister = InMemoryPersister().asFlowable() - val pipeline = - StoreBuilder.from( - Fetcher.of { - throw exception - }, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .disableCache() - .buildWithTestScope() - - launch { - delay(10) - persister.flowWriter(3, "local-1") - } - - pipeline.stream(StoreReadRequest.cached(key = 3, refresh = true)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Error.Exception( - error = exception, - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "local-1", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.cached(key = 3, refresh = true)).test { - assertEquals( - Data( - value = "local-1", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Error.Exception( - error = exception, - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } - - @Test - fun givenSourceOfTruthWhenStreamFreshDataReturnsNoDataFromFetcherThenFetchReturnsNoDataAndCachedValuesAreReceived() = - testScope.runTest { - val persister = InMemoryPersister().asFlowable() - val pipeline = - StoreBuilder.from( - fetcher = Fetcher.ofFlow { flow {} }, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .buildWithTestScope() - - persister.flowWriter(3, "local-1") - val firstFetch = pipeline.fresh(3) // prime the cache - assertEquals("local-1", firstFetch) - - pipeline.stream(StoreReadRequest.fresh(3)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.NoNewData( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "local-1", - origin = StoreReadResponseOrigin.Cache, - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "local-1", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - } - } - - @Test - fun givenSourceOfTruthWhenStreamCachedDataWithRefreshReturnsNoNewDataThenCachedValuesAreReceivedAndFetchReturnsNoData() = - testScope.runTest { - val persister = InMemoryPersister().asFlowable() - val pipeline = - StoreBuilder.from( - fetcher = Fetcher.ofFlow { flow {} }, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .buildWithTestScope() - - persister.flowWriter(3, "local-1") - val firstFetch = pipeline.fresh(3) // prime the cache - assertEquals("local-1", firstFetch) - - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - Data( - value = "local-1", - origin = StoreReadResponseOrigin.Cache, - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "local-1", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.NoNewData( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } - - @Test - fun givenNoSourceOfTruthWhenStreamFreshDataReturnsNoDataFromFetcherThenFetchReturnsNoDataAndCachedValuesAreReceived() = - testScope.runTest { - var createCount = 0 - val pipeline = - StoreBuilder.from( - fetcher = - Fetcher.ofFlow { - if (createCount++ == 0) { - flowOf("remote-1") - } else { - flowOf() - } - }, - ) - .buildWithTestScope() - - val firstFetch = pipeline.fresh(3) // prime the cache - assertEquals("remote-1", firstFetch) - - pipeline.stream(StoreReadRequest.fresh(3)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.NoNewData( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "remote-1", - origin = StoreReadResponseOrigin.Cache, - ), - awaitItem(), - ) - } - } - - @Test - fun givenNoSoTWhenStreamCachedDataWithRefreshReturnsNoNewDataThenCachedValuesAreReceivedAndFetchReturnsNoData() = - testScope.runTest { - var createCount = 0 - val pipeline = - StoreBuilder.from( - fetcher = - Fetcher.ofFlow { - if (createCount++ == 0) { - flowOf("remote-1") - } else { - flowOf() - } - }, - ) - .buildWithTestScope() - - val firstFetch = pipeline.fresh(3) // prime the cache - assertEquals("remote-1", firstFetch) - - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - Data( - value = "remote-1", - origin = StoreReadResponseOrigin.Cache, - ), - awaitItem(), - ) - - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.NoNewData( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } - - @Test - fun givenNoSourceOfTruthAndCacheHitWhenStreamCachedDataWithoutRefreshThenNoFetchIsTriggeredAndReceivesFollowingNetworkUpdates() = - testScope.runTest { - val fetcher = - FakeFetcher( - 3 to "three-1", - 3 to "three-2", - ) - val store = - StoreBuilder.from(fetcher = fetcher) - .buildWithTestScope() - - val firstFetch = store.fresh(3) - assertEquals("three-1", firstFetch) - val secondCollect = mutableListOf>() - val collection = - launch { - store.stream(StoreReadRequest.cached(3, refresh = false)).collect { - secondCollect.add(it) - } - } - testScope.runCurrent() - assertEquals(1, secondCollect.size) - assertContains( - secondCollect, - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Cache, - ), - ) - // trigger another fetch from network - val secondFetch = store.fresh(3) - assertEquals("three-2", secondFetch) - testScope.runCurrent() - // make sure cached also received it - assertEquals(2, secondCollect.size) - - assertContains( - secondCollect, - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Cache, - ), - ) - assertContains( - secondCollect, - Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - ) - - collection.cancelAndJoin() - } - - @Test - fun givenSourceOfTruthAndCacheHitWhenStreamCachedDataWithoutRefreshThenNoFetchIsTriggeredAndReceivesFollowingNetworkUpdates() = - testScope.runTest { - val fetcher = - FakeFetcher( - 3 to "three-1", - 3 to "three-2", - ) - val persister = InMemoryPersister() - val pipeline = - StoreBuilder.from( - fetcher = fetcher, - sourceOfTruth = persister.asSourceOfTruth(), - ).buildWithTestScope() - - val firstFetch = pipeline.fresh(3) - assertEquals("three-1", firstFetch) - val secondCollect = mutableListOf>() - val collection = - launch { - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).collect { - secondCollect.add(it) - } - } - testScope.runCurrent() - assertEquals(2, secondCollect.size) - - assertContains( - secondCollect, - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Cache, - ), - ) - assertContains( - secondCollect, - Data( - value = "three-1", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - ) - - // trigger another fetch from network - val secondFetch = pipeline.fresh(3) - assertEquals("three-2", secondFetch) - testScope.runCurrent() - // make sure cached also received it - assertEquals(3, secondCollect.size) - - assertContains( - secondCollect, - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Cache, - ), - ) - assertContains( - secondCollect, - Data( - value = "three-1", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - ) - - assertContains( - secondCollect, - Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - ) - collection.cancelAndJoin() - } - - @Test - fun testSlowFirstCollectorGetsAllFetchUpdatesOthersGetCacheAndLatestFetchResult() = - testScope.runTest { - val fetcher = - FakeFetcher( - 3 to "three-1", - 3 to "three-2", - 3 to "three-3", - ) - val pipeline = - StoreBuilder.from( - fetcher = fetcher, - ).buildWithTestScope() - - val fetcher1Collected = mutableListOf>() - val fetcher1Job = - async { - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).collect { - fetcher1Collected.add(it) - delay(1_000) - } - } - testScope.advanceUntilIdle() - assertEquals( - listOf( - Loading(origin = StoreReadResponseOrigin.Fetcher()), - Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-1"), - ), - fetcher1Collected, - ) - - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - Data(origin = StoreReadResponseOrigin.Cache, value = "three-1"), - awaitItem(), - ) - assertEquals( - Loading(origin = StoreReadResponseOrigin.Fetcher()), - awaitItem(), - ) - - assertEquals( - Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-2"), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - Data(origin = StoreReadResponseOrigin.Cache, value = "three-2"), - awaitItem(), - ) - - assertEquals( - Loading(origin = StoreReadResponseOrigin.Fetcher()), - awaitItem(), - ) - assertEquals( - Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-3"), - awaitItem(), - ) - } - - testScope.advanceUntilIdle() - assertEquals( - listOf( - Loading(origin = StoreReadResponseOrigin.Fetcher()), - Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-1"), - Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-2"), - Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-3"), - ), - fetcher1Collected, - ) - - fetcher1Job.cancelAndJoin() - } - - @Test - fun testFirstStreamGetsTwoFetchUpdatesSecondGetsCacheAndFetchResult() = - testScope.runTest { - val fetcher = - FakeFetcher( - 3 to "three-1", - 3 to "three-2", - ) - val pipeline = - StoreBuilder.from(fetcher = fetcher) - .buildWithTestScope() - - val fetcher1Collected = mutableListOf>() - val fetcher1Job = - async { - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).collect { - fetcher1Collected.add(it) - } - } - testScope.runCurrent() - assertEquals( - listOf( - Loading(origin = StoreReadResponseOrigin.Fetcher()), - Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-1"), - ), - fetcher1Collected, - ) - - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - Data(origin = StoreReadResponseOrigin.Cache, value = "three-1"), - awaitItem(), - ) - - assertEquals( - Loading(origin = StoreReadResponseOrigin.Fetcher()), - awaitItem(), - ) - - assertEquals( - Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-2"), - awaitItem(), - ) - } - - testScope.runCurrent() - assertEquals( - listOf( - Loading(origin = StoreReadResponseOrigin.Fetcher()), - Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-1"), - Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-2"), - ), - fetcher1Collected, - ) - - fetcher1Job.cancelAndJoin() - } - - suspend fun Store.get(request: StoreReadRequest) = this.stream(request).filter { it.dataOrNull() != null }.first() - - suspend fun Store.get(key: Int) = - get( - StoreReadRequest.cached( - key = key, - refresh = false, - ), - ) - - private fun StoreBuilder.buildWithTestScope() = scope(testScope).build() - - @Test - fun stream_givenConverterThrows_thenEmitsError() = - testScope.runTest { - // Given - val exception = IllegalStateException("Converter failed") - val persister = InMemoryPersister() - - val pipeline = - StoreBuilder.from( - fetcher = Fetcher.of { _: Int -> "network" }, - sourceOfTruth = persister.asSourceOfTruth(), - converter = - object : Converter { - override fun fromNetworkToLocal(network: String): String { - throw exception - } - - override fun fromOutputToLocal(output: String): String = output - }, - ).buildWithTestScope() - - // When + Then - pipeline.stream(StoreReadRequest.fresh(1)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - val errorResponse = awaitItem() - assertIs(errorResponse) - assertEquals(exception.message, errorResponse.error.message) - } - } - - @Test - fun stream_givenNamedFetcherAndConverterThrows_thenErrorContainsFetcherName() = - testScope.runTest { - // Given - val fetcherName = "TestFetcher" - val exception = IllegalStateException("Converter failed") - val persister = InMemoryPersister() - - val pipeline = - StoreBuilder.from( - fetcher = Fetcher.of(name = fetcherName) { _: Int -> "network" }, - sourceOfTruth = persister.asSourceOfTruth(), - converter = - object : Converter { - override fun fromNetworkToLocal(network: String): String { - throw exception - } - - override fun fromOutputToLocal(output: String): String = output - }, - ).buildWithTestScope() - - // When + Then - pipeline.stream(StoreReadRequest.fresh(1)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - val errorResponse = awaitItem() - assertIs(errorResponse) - val origin = errorResponse.origin - assertIs(origin) - assertEquals(fetcherName, origin.name) - } - } - - @Test - fun stream_givenConverterThrowsWithFreshRequest_thenFlowCompletes() = - testScope.runTest { - // Given: fresh() request skips disk cache and fallBackToSourceOfTruth defaults to false - val exception = IllegalStateException("Converter failed") - val persister = InMemoryPersister() - - val pipeline = - StoreBuilder.from( - fetcher = Fetcher.of { _: Int -> "network" }, - sourceOfTruth = persister.asSourceOfTruth(), - converter = - object : Converter { - override fun fromNetworkToLocal(network: String): String { - throw exception - } - - override fun fromOutputToLocal(output: String): String = output - }, - ).buildWithTestScope() - - // When + Then: Flow should complete, not hang indefinitely - pipeline.stream(StoreReadRequest.fresh(1)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - val errorResponse = awaitItem() - assertIs(errorResponse) - assertEquals(exception.message, errorResponse.error.message) - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun stream_givenConverterThrowsWithFallbackDisabled_thenDiskDataNotEmitted() = - testScope.runTest { - // Given: Pre-populate disk with data, then request fresh with fallBackToSourceOfTruth=false - val exception = IllegalStateException("Converter failed") - val persister = InMemoryPersister() - persister.write(1, "cached value") - - val pipeline = - StoreBuilder.from( - fetcher = Fetcher.of { _: Int -> "network" }, - sourceOfTruth = persister.asSourceOfTruth(), - converter = - object : Converter { - override fun fromNetworkToLocal(network: String): String { - throw exception - } - - override fun fromOutputToLocal(output: String): String = output - }, - ).buildWithTestScope() - - // When: Request with fallBackToSourceOfTruth=false - pipeline.stream(StoreReadRequest.fresh(1, fallBackToSourceOfTruth = false)).test { - assertEquals( - Loading(origin = StoreReadResponseOrigin.Fetcher()), - awaitItem(), - ) - - // Then: Only error is emitted, no disk data (since fallback is disabled) - val errorResponse = awaitItem() - assertIs(errorResponse) - assertEquals(exception.message, errorResponse.error.message) - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun stream_givenConverterThrowsWithFallbackEnabled_thenDiskDataEmitted() = - testScope.runTest { - // Given: Pre-populate disk with data, then request with fallBackToSourceOfTruth=true - val exception = IllegalStateException("Converter failed") - val persister = InMemoryPersister() - persister.write(1, "cached value") - - val pipeline = - StoreBuilder.from( - fetcher = Fetcher.of { _: Int -> "network" }, - sourceOfTruth = persister.asSourceOfTruth(), - converter = - object : Converter { - override fun fromNetworkToLocal(network: String): String { - throw exception - } - - override fun fromOutputToLocal(output: String): String = output - }, - ).buildWithTestScope() - - // When: Request with fallBackToSourceOfTruth=true - pipeline.stream(StoreReadRequest.fresh(1, fallBackToSourceOfTruth = true)).test { - assertEquals( - Loading(origin = StoreReadResponseOrigin.Fetcher()), - awaitItem(), - ) - - // Then: Error is emitted - val errorResponse = awaitItem() - assertIs(errorResponse) - - // And: Disk data is also emitted (since fallback is enabled) - val diskData = awaitItem() - assertIs>(diskData) - assertEquals("cached value", diskData.value) - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun stream_givenConverterFailsThenSucceeds_thenSecondRequestEmitsData() = - testScope.runTest { - // Given: Converter that fails on first attempt and succeeds on second - var attempts = 0 - val exception = IllegalStateException("Converter failed") - val persister = InMemoryPersister() - - val pipeline = - StoreBuilder.from( - fetcher = Fetcher.of { _: Int -> "network value" }, - sourceOfTruth = persister.asSourceOfTruth(), - converter = - object : Converter { - override fun fromNetworkToLocal(network: String): String { - attempts++ - if (attempts == 1) { - throw exception - } - return network - } - - override fun fromOutputToLocal(output: String): String = output - }, - ).buildWithTestScope() - - // First request: fresh with fallback disabled (should error) - pipeline.stream(StoreReadRequest.fresh(1, fallBackToSourceOfTruth = false)).test { - assertEquals( - Loading(origin = StoreReadResponseOrigin.Fetcher()), - awaitItem(), - ) - val errorResponse = awaitItem() - assertIs(errorResponse) - assertEquals(exception.message, errorResponse.error.message) - cancelAndIgnoreRemainingEvents() - } - - // Second request: fresh again (should succeed and emit data) - pipeline.stream(StoreReadRequest.fresh(1, fallBackToSourceOfTruth = false)).test { - assertEquals( - Loading(origin = StoreReadResponseOrigin.Fetcher()), - awaitItem(), - ) - - // Should receive data (Fetcher origin from SOT), not be skipped - val dataResponse = awaitItem() - assertIs>(dataResponse) - assertEquals("network value", dataResponse.value) - cancelAndIgnoreRemainingEvents() - } - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/HotFlowStoreTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/HotFlowStoreTests.kt deleted file mode 100644 index 7d3c2e64f..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/HotFlowStoreTests.kt +++ /dev/null @@ -1,102 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import app.cash.turbine.test -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals - -@ExperimentalCoroutinesApi -@FlowPreview -class HotFlowStoreTests { - private val testScope = TestScope() - - @Test - fun givenAHotFetcherWhenTwoCachedAndOneFreshCallThenFetcherIsOnlyCalledTwice() = - testScope.runTest { - val fetcher = - FakeFlowFetcher( - 3 to "three-1", - 3 to "three-2", - ) - val pipeline = - StoreBuilder - .from(fetcher) - .scope(testScope) - .build() - - val job = - launch { - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - pipeline.stream( - StoreReadRequest.cached(3, refresh = false), - ).test { - assertEquals( - StoreReadResponse.Data( - value = "three-1", - origin = StoreReadResponseOrigin.Cache, - ), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.fresh(3)).test { - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } - - job.cancel() - } -} - -private class FakeFlowFetcher( - vararg val responses: Pair, -) : Fetcher { - private var index = 0 - override val name: String? = null - - override val fallback: Fetcher? = null - - override fun invoke(key: Key): Flow> { - if (index >= responses.size) { - throw AssertionError("unexpected fetch request") - } - val pair = responses[index++] - assertEquals(key, pair.first) - return flowOf(FetcherResult.Data(pair.second)) - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/KeyTrackerTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/KeyTrackerTests.kt deleted file mode 100644 index a38fb4d10..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/KeyTrackerTests.kt +++ /dev/null @@ -1,136 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.async -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.collectIndexed -import kotlinx.coroutines.flow.take -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.mobilenativefoundation.store.store5.util.KeyTracker -import kotlin.test.Test -import kotlin.test.assertEquals - -@ExperimentalCoroutinesApi -class KeyTrackerTests { - private val scope1 = TestScope() - private val scope2 = TestScope() - - private val subject = KeyTracker() - - @Test - fun dontSkipInvalidations() = - scope1.runTest { - val collection = - scope2.async { - subject.keyFlow('b') - .take(2) - .toList() - } - scope2.advanceUntilIdle() - assertEquals(1, subject.activeKeyCount()) - scope2.advanceUntilIdle() - subject.invalidate('a') - subject.invalidate('b') - subject.invalidate('c') - scope2.advanceUntilIdle() - assertEquals(true, collection.isCompleted) - assertEquals(0, subject.activeKeyCount()) - } - - @Test - fun multipleScopes() = - scope1.runTest { - val keys = 'a'..'z' - val collections = - keys.associate { key -> - key to - scope2.async { - subject.keyFlow(key) - .take(2) - .toList() - } - } - scope2.advanceUntilIdle() - assertEquals(26, subject.activeKeyCount()) - - scope2.advanceUntilIdle() - keys.forEach { - subject.invalidate(it) - } - scope2.advanceUntilIdle() - - collections.forEach { (_, deferred) -> - assertEquals(true, deferred.isCompleted) - } - assertEquals(0, subject.activeKeyCount()) - } - - @Test - fun multipleObservers() = - scope1.runTest { - val collections = - (0..4).map { - scope2.async { - subject.keyFlow('b') - .take(2) - .toList() - } - } - scope2.advanceUntilIdle() - assertEquals(1, subject.activeKeyCount()) - scope2.advanceUntilIdle() - subject.invalidate('a') - subject.invalidate('b') - subject.invalidate('c') - scope2.advanceUntilIdle() - collections.forEach { collection -> - assertEquals(true, collection.isCompleted) - } - assertEquals(0, subject.activeKeyCount()) - } - - @Test - fun keyFlow_notCollected_shouldNotBeTracked() = - scope1.runTest { - val flow = subject.keyFlow('b') - assertEquals(0, subject.activeKeyCount()) - scope2.launch { - flow.collectIndexed { index, value -> - assertEquals(1, index) - assertEquals(Unit, value) - assertEquals(1, subject.activeKeyCount()) - cancel() - } - } - assertEquals(0, subject.activeKeyCount()) - } - - @Test - fun keyFlow_trackerShouldRefCount() = - scope1.runTest { - val flow = subject.keyFlow('a') - assertEquals(0, subject.activeKeyCount()) - scope2.launch { - flow.collectIndexed { index, value -> - assertEquals(1, index) - assertEquals(Unit, value) - assertEquals(1, subject.activeKeyCount()) - cancel() - } - } - scope2.launch { - flow.collectIndexed { index, value -> - assertEquals(1, index) - assertEquals(Unit, value) - assertEquals(1, subject.activeKeyCount()) - cancel() - } - } - - assertEquals(0, subject.activeKeyCount()) - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/LocalOnlyTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/LocalOnlyTests.kt deleted file mode 100644 index 632478bda..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/LocalOnlyTests.kt +++ /dev/null @@ -1,168 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import kotlinx.atomicfu.atomic -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.mobilenativefoundation.store.store5.impl.extensions.get -import org.mobilenativefoundation.store.store5.util.InMemoryPersister -import org.mobilenativefoundation.store.store5.util.asSourceOfTruth -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue -import kotlin.time.Duration - -class LocalOnlyTests { - private val testScope = TestScope() - - @Test - fun givenEmptyMemoryCacheThenCacheOnlyRequestReturnsNoNewData() = - testScope.runTest { - val store = - StoreBuilder - .from(Fetcher.of { _: Int -> throw RuntimeException("Fetcher shouldn't be hit") }) - .cachePolicy( - MemoryPolicy - .builder() - .build(), - ) - .build() - val response = store.stream(StoreReadRequest.localOnly(0)).first() - assertEquals(StoreReadResponse.NoNewData(StoreReadResponseOrigin.Cache), response) - } - - @Test - fun givenPrimedMemoryCacheThenCacheOnlyRequestReturnsData() = - testScope.runTest { - val fetcherHitCounter = atomic(0) - val store = - StoreBuilder - .from( - Fetcher.of { _: Int -> - fetcherHitCounter += 1 - "result" - }, - ) - .cachePolicy( - MemoryPolicy - .builder() - .build(), - ) - .build() - val a = store.get(0) - assertEquals("result", a) - assertEquals(1, fetcherHitCounter.value) - val response = store.stream(StoreReadRequest.localOnly(0)).first() - assertEquals("result", response.requireData()) - assertEquals(1, fetcherHitCounter.value) - } - - @Test - fun givenInvalidMemoryCacheThenCacheOnlyRequestReturnsNoNewData() = - testScope.runTest { - val fetcherHitCounter = atomic(0) - val store = - StoreBuilder - .from( - Fetcher.of { _: Int -> - fetcherHitCounter += 1 - "result" - }, - ) - .cachePolicy( - MemoryPolicy - .builder() - .setExpireAfterWrite(Duration.ZERO) - .build(), - ) - .build() - val a = store.get(0) - assertEquals("result", a) - assertEquals(1, fetcherHitCounter.value) - val response = store.stream(StoreReadRequest.localOnly(0)).first() - assertEquals(StoreReadResponse.NoNewData(StoreReadResponseOrigin.Cache), response) - assertEquals(1, fetcherHitCounter.value) - } - - @Test - fun givenEmptyDiskCacheThenCacheOnlyRequestReturnsNoNewData() = - testScope.runTest { - val persister = InMemoryPersister() - val store = - StoreBuilder - .from( - fetcher = Fetcher.of { _: Int -> throw RuntimeException("Fetcher shouldn't be hit") }, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .disableCache() - .build() - val response = store.stream(StoreReadRequest.localOnly(0)).first() - assertEquals(StoreReadResponse.NoNewData(StoreReadResponseOrigin.SourceOfTruth), response) - } - - @Test - fun givenPrimedDiskCacheThenCacheOnlyRequestReturnsData() = - testScope.runTest { - val fetcherHitCounter = atomic(0) - val persister = InMemoryPersister() - val store = - StoreBuilder - .from( - fetcher = - Fetcher.of { _: Int -> - fetcherHitCounter += 1 - "result" - }, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .disableCache() - .build() - val a = store.get(0) - assertEquals("result", a) - assertEquals(1, fetcherHitCounter.value) - val response = store.stream(StoreReadRequest.localOnly(0)).first() - assertEquals("result", response.requireData()) - assertEquals(StoreReadResponseOrigin.SourceOfTruth, response.origin) - assertEquals(1, fetcherHitCounter.value) - } - - @Test - fun givenInvalidDiskCacheThenCacheOnlyRequestReturnsNoNewData() = - testScope.runTest { - val fetcherHitCounter = atomic(0) - val persister = InMemoryPersister() - persister.write(0, "result") - val store = - StoreBuilder - .from( - fetcher = - Fetcher.of { _: Int -> - fetcherHitCounter += 1 - "result" - }, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .disableCache() - .validator(Validator.by { false }) - .build() - val a = store.get(0) - assertEquals("result", a) - assertEquals(1, fetcherHitCounter.value) - val response = store.stream(StoreReadRequest.localOnly(0)).first() - assertEquals(StoreReadResponse.NoNewData(StoreReadResponseOrigin.SourceOfTruth), response) - assertEquals(1, fetcherHitCounter.value) - } - - @Test - fun givenNoCacheThenCacheOnlyRequestReturnsNoNewData() = - testScope.runTest { - val store = - StoreBuilder - .from(Fetcher.of { _: Int -> throw RuntimeException("Fetcher shouldn't be hit") }) - .disableCache() - .build() - val response = store.stream(StoreReadRequest.localOnly(0)).first() - assertTrue(response is StoreReadResponse.NoNewData) - assertEquals(StoreReadResponseOrigin.Cache, response.origin) - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/MapIndexedTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/MapIndexedTests.kt deleted file mode 100644 index da4527c70..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/MapIndexedTests.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import app.cash.turbine.test -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.mobilenativefoundation.store.store5.impl.operators.mapIndexed -import kotlin.test.Test -import kotlin.test.assertEquals - -class MapIndexedTests { - private val scope = TestScope() - - @Test - fun mapIndexed() = - scope.runTest { - flowOf(5, 6).mapIndexed { index, value -> index to value }.test { - assertEquals(0 to 5, awaitItem()) - assertEquals(1 to 6, awaitItem()) - awaitComplete() - } - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/SourceOfTruthErrorsTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/SourceOfTruthErrorsTests.kt deleted file mode 100644 index 79808aaf6..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/SourceOfTruthErrorsTests.kt +++ /dev/null @@ -1,453 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import app.cash.turbine.test -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.mobilenativefoundation.store.store5.SourceOfTruth.ReadException -import org.mobilenativefoundation.store.store5.SourceOfTruth.WriteException -import org.mobilenativefoundation.store.store5.util.FakeFetcher -import org.mobilenativefoundation.store.store5.util.InMemoryPersister -import org.mobilenativefoundation.store.store5.util.asSourceOfTruth -import kotlin.test.Test -import kotlin.test.assertEquals - -@OptIn(ExperimentalCoroutinesApi::class) -@FlowPreview -class SourceOfTruthErrorsTests { - private val testScope = TestScope() - - @Test - fun givenSourceOfTruthWhenWriteFailsThenExceptionShouldBeSendToTheCollector() = - testScope.runTest { - val persister = InMemoryPersister() - val fetcher = - FakeFetcher( - 3 to "a", - 3 to "b", - ) - val pipeline = - StoreBuilder - .from( - fetcher = fetcher, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .scope(testScope) - .build() - persister.preWriteCallback = { _, _ -> - throw TestException("i fail") - } - - pipeline.stream(StoreReadRequest.fresh(3)).test { - assertEquals( - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Error.Exception( - error = - WriteException( - key = 3, - value = "a", - cause = TestException("i fail"), - ), - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - } - } - - @Test - fun givenSourceOfTruthWhenReadFailsThenExceptionShouldBeSendToTheCollector() = - testScope.runTest { - val persister = InMemoryPersister() - val fetcher = - FakeFetcher( - 3 to "a", - 3 to "b", - ) - val pipeline = - StoreBuilder - .from( - fetcher = fetcher, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .scope(testScope) - .build() - - persister.postReadCallback = { _, value -> - throw TestException(value ?: "null") - } - - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { - assertEquals( - StoreReadResponse.Error.Exception( - error = - ReadException( - key = 3, - cause = TestException("null"), - ), - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - - // after disk fails, we should still invoke fetcher - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - // and after fetcher writes the value, it will trigger another read which will also - // fail - assertEquals( - StoreReadResponse.Error.Exception( - error = - ReadException( - key = 3, - cause = TestException("a"), - ), - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - } - } - - @Test - fun givenSourceOfTruthWhenFirstWriteFailsThenItShouldKeepReadingFromFetcher() = - testScope.runTest { - val persister = InMemoryPersister() - val fetcher = - Fetcher.ofFlow { _: Int -> - flowOf("a", "b", "c", "d") - } - val pipeline = - StoreBuilder - .from( - fetcher = fetcher, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .disableCache() - .scope(testScope) - .build() - persister.preWriteCallback = { _, value -> - if (value in listOf("a", "c")) { - throw TestException(value) - } - value - } - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - assertEquals( - StoreReadResponse.Error.Exception( - error = - WriteException( - key = 3, - value = "a", - cause = TestException("a"), - ), - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Data( - value = "b", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Error.Exception( - error = - WriteException( - key = 3, - value = "c", - cause = TestException("c"), - ), - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - - // disk flow will restart after a failed write (because we stopped it before the - // write attempt starts, so we will get the disk value again). - assertEquals( - StoreReadResponse.Data( - value = "b", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Data( - value = "d", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } - -// @Test -// fun givenSourceOfTruthWithFailingWriteWhenAPassiveReaderArrivesThenItShouldReceiveTheNewWriteError() = testScope.runTest { -// val persister = InMemoryPersister() -// val fetcher = Fetcher.ofFlow { _: Int -> -// flowOf("a", "b", "c", "d") -// } -// val pipeline = StoreBuilder -// .from( -// fetcher = fetcher, -// sourceOfTruth = persister.asSourceOfTruth() -// ) -// .disableCache() -// .scope(testScope) -// .build() -// persister.preWriteCallback = { _, value -> -// if (value in listOf("a", "c")) { -// delay(50) -// throw TestException(value) -// } else { -// delay(10) -// } -// value -// } -// // keep collection hot -// val collector = launch { -// pipeline.stream( -// StoreReadRequest.cached(3, refresh = true) -// ).toList() -// } -// -// // miss writes for a and b and let the write operation for c start such that -// // we'll catch that write error -// delay(70) -// assertEmitsExactly( -// pipeline.stream(StoreReadRequest.cached(3, refresh = true)), -// listOf( -// // we wanted the disk value but write failed so we don't get it -// StoreReadResponse.Error.Exception( -// error = WriteException( -// key = 3, -// value = "c", -// cause = TestException("c") -// ), -// origin = StoreReadResponseOrigin.SourceOfTruth -// ), -// // after the write error, we should get the value on disk -// StoreReadResponse.Data( -// value = "b", -// origin = StoreReadResponseOrigin.SourceOfTruth -// ), -// // now we'll unlock the fetcher after disk is read -// StoreReadResponse.Loading( -// origin = StoreReadResponseOrigin.Fetcher( -// ), -// StoreReadResponse.Data( -// value = "d", -// origin = StoreReadResponseOrigin.Fetcher( -// ) -// ) -// ) -// collector.cancelAndJoin() -// } - - @Test - fun givenSourceOfTruthWithFailingWriteWhenAPassiveReaderArrivesThenItShouldNotGetErrorsHappenedBefore() = - testScope.runTest { - val persister = InMemoryPersister() - val fetcher = - Fetcher.ofFlow { - flow { - emit("a") - emit("b") - emit("c") - // now delay, wait for the new subscriber - delay(100) - emit("d") - } - } - val pipeline = - StoreBuilder - .from( - fetcher = fetcher, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .disableCache() - .scope(testScope) - .build() - persister.preWriteCallback = { _, value -> - if (value in listOf("a", "c")) { - throw TestException(value) - } - value - } - val collector = - launch { - pipeline.stream( - StoreReadRequest.cached(3, refresh = true), - ).toList() // keep collection hot - } - - // miss both failures but arrive before d is fetched - delay(70) - - pipeline.stream(StoreReadRequest.skipMemory(3, refresh = true)).test { - assertEquals( - StoreReadResponse.Data( - value = "b", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - - // don't receive the write exception because technically it started before we - // started reading - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Data( - value = "d", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - collector.cancelAndJoin() - } - -// @Test -// fun givenSourceOfTruthWithFailingWriteWhenAFreshValueReaderArrivesThenItShouldNotGetDiskErrorsFromAPendingWrite() = testScope.runTest { -// val persister = InMemoryPersister() -// val fetcher = Fetcher.ofFlow { -// flowOf("a", "b", "c", "d") -// } -// val pipeline = StoreBuilder -// .from( -// fetcher = fetcher, -// sourceOfTruth = persister.asSourceOfTruth() -// ) -// .disableCache() -// .scope(testScope) -// .build() -// persister.preWriteCallback = { _, value -> -// if (value == "c") { -// // slow down read so that the new reader arrives -// delay(50) -// } -// if (value in listOf("a", "c")) { -// throw TestException(value) -// } -// value -// } -// val collector = launch { -// pipeline.stream( -// StoreReadRequest.cached(3, refresh = true) -// ).toList() // keep collection hot -// } -// // miss both failures but arrive before d is fetched -// delay(20) -// assertEmitsExactly( -// pipeline.stream(StoreReadRequest.fresh(3)), -// listOf( -// StoreReadResponse.Loading( -// origin = StoreReadResponseOrigin.Fetcher( -// ), -// StoreReadResponse.Data( -// value = "d", -// origin = StoreReadResponseOrigin.Fetcher( -// ) -// ) -// ) -// collector.cancelAndJoin() -// } - - @Test - fun givenSourceOfTruthWithReadFailureWhenCachedValueReaderArrivesThenFetcherShouldBeCalledToGetANewValue() { - testScope.runTest { - val persister = InMemoryPersister() - val fetcher = Fetcher.of { _: Int -> "a" } - val pipeline = - StoreBuilder - .from( - fetcher = fetcher, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .disableCache() - .scope(testScope) - .build() - persister.postReadCallback = { _, value -> - if (value == null) { - throw TestException("first read") - } - value - } - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - StoreReadResponse.Error.Exception( - origin = StoreReadResponseOrigin.SourceOfTruth, - error = - ReadException( - key = 3, - cause = TestException("first read"), - ), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Data( - value = "a", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } - } - - private class TestException(val msg: String) : Exception(msg) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is TestException) return false - return msg == other.msg - } - - override fun hashCode(): Int { - return msg.hashCode() - } - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/SourceOfTruthWithBarrierTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/SourceOfTruthWithBarrierTests.kt deleted file mode 100644 index 656f9b91c..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/SourceOfTruthWithBarrierTests.kt +++ /dev/null @@ -1,285 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.mobilenativefoundation.store.store5 - -import app.cash.turbine.test -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.async -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.take -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.mobilenativefoundation.store.store5.SourceOfTruth.ReadException -import org.mobilenativefoundation.store.store5.SourceOfTruth.WriteException -import org.mobilenativefoundation.store.store5.impl.PersistentSourceOfTruth -import org.mobilenativefoundation.store.store5.impl.SourceOfTruthWithBarrier -import org.mobilenativefoundation.store.store5.util.InMemoryPersister -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull - -@FlowPreview -@ExperimentalCoroutinesApi -class SourceOfTruthWithBarrierTests { - private val testScope = TestScope() - private val persister = InMemoryPersister() - private val delegate: SourceOfTruth = - PersistentSourceOfTruth( - realReader = { key -> - flow { - emit(persister.read(key)) - } - }, - realWriter = persister::write, - realDelete = persister::deleteByKey, - realDeleteAll = persister::deleteAll, - ) - private val source = - SourceOfTruthWithBarrier( - delegate = delegate, - ) - - @Test - fun simple() = - testScope.runTest { - val collection = mutableListOf>() - - launch { - source.reader(1, CompletableDeferred(Unit)).take(2).collect { - collection.add(it) - } - } - delay(500) - source.write(1, "a") - advanceUntilIdle() - assertEquals( - listOf>( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.SourceOfTruth, - value = null, - ), - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = "a", - ), - ), - collection, - ) - assertEquals(0, source.barrierCount()) - } - - @Test - fun givenASourceOfTruthWhenDeleteIsCalledThenItIsDelegatedToThePersister() = - testScope.runTest { - persister.write(1, "a") - source.delete(1) - assertNull(persister.read(1)) - } - - @Test - fun givenASourceOfTruthWhenDeleteAllIsCalledThenItIsDelegatedToThePersister() = - testScope.runTest { - persister.write(1, "a") - persister.write(2, "b") - source.deleteAll() - assertNull(persister.read(1)) - assertNull(persister.read(2)) - } - - @Test - fun preAndPostWrites() = - testScope.runTest { - val collection = mutableListOf>() - source.write(1, "a") - - launch { - source.reader(1, CompletableDeferred(Unit)).take(2).collect { - collection.add(it) - } - } - - delay(200) - - source.write(1, "b") - - advanceUntilIdle() - - assertEquals( - listOf>( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.SourceOfTruth, - value = "a", - ), - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = "b", - ), - ), - collection, - ) - - assertEquals(0, source.barrierCount()) - } - - @Test - fun givenSourceOfTruthWhenReadFailsThenErrorShouldPropagate() = - testScope.runTest { - val exception = RuntimeException("read fails") - persister.postReadCallback = { key, value -> - throw exception - } - - source.reader(1, CompletableDeferred(Unit)).test { - assertEquals( - StoreReadResponse.Error.Exception( - origin = StoreReadResponseOrigin.SourceOfTruth, - error = - ReadException( - key = 1, - cause = exception, - ), - ), - awaitItem(), - ) - } - } - - @Test - fun givenSourceOfTruthWhenReadFailsButThenSucceedsThenErrorShouldPropagateButAlsoTheValue() = - testScope.runTest { - var hasThrown = false - val exception = RuntimeException("read fails") - persister.postReadCallback = { _, value -> - if (!hasThrown) { - hasThrown = true - throw exception - } - value - } - val reader = source.reader(1, CompletableDeferred(Unit)) - val collected = mutableListOf>() - val collection = - async { - reader.collect { - collected.add(it) - } - } - advanceUntilIdle() - assertEquals( - StoreReadResponse.Error.Exception( - origin = StoreReadResponseOrigin.SourceOfTruth, - error = - ReadException( - key = 1, - cause = exception, - ), - ), - collected.first(), - ) - // make sure it is not cancelled for the read error - assertEquals(true, collection.isActive) - // now insert another, it should trigger another read and emitted to the reader - source.write(1, "a") - advanceUntilIdle() - assertEquals( - listOf>( - StoreReadResponse.Error.Exception( - origin = StoreReadResponseOrigin.SourceOfTruth, - error = - ReadException( - key = 1, - cause = exception, - ), - ), - StoreReadResponse.Data( - // this is fetcher since we are using the write API - origin = StoreReadResponseOrigin.Fetcher(), - value = "a", - ), - ), - collected, - ) - collection.cancelAndJoin() - } - - @Test - fun givenSourceOfTruthWhenWriteFailsThenErrorShouldPropagate() { - val failValue = "will fail" - testScope.runTest { - val exception = RuntimeException("write fails") - persister.preWriteCallback = { key, value -> - if (value == failValue) { - throw exception - } - value - } - val reader = source.reader(1, CompletableDeferred(Unit)) - val collected = mutableListOf>() - val collection = - async { - reader.collect { - collected.add(it) - } - } - advanceUntilIdle() - source.write(1, failValue) - advanceUntilIdle() - // make sure collection does not cancel for a write error - assertEquals(true, collection.isActive) - val eventsUntilFailure = - listOf( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.SourceOfTruth, - value = null, - ), - StoreReadResponse.Error.Exception( - origin = StoreReadResponseOrigin.SourceOfTruth, - error = - WriteException( - key = 1, - value = failValue, - cause = exception, - ), - ), - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.SourceOfTruth, - value = null, - ), - ) - assertEquals(eventsUntilFailure, collected) - advanceUntilIdle() - assertEquals(true, collection.isActive) - // send another write that will succeed - source.write(1, "succeed") - advanceUntilIdle() - assertEquals( - eventsUntilFailure + - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = "succeed", - ), - collected, - ) - collection.cancelAndJoin() - } - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/StoreReadResponseTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/StoreReadResponseTests.kt deleted file mode 100644 index f1d2c59ff..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/StoreReadResponseTests.kt +++ /dev/null @@ -1,52 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertNull - -class StoreReadResponseTests { - @Test - fun requireData() { - assertEquals("Foo", StoreReadResponse.Data("Foo", StoreReadResponseOrigin.Fetcher()).requireData()) - - // should throw - assertFailsWith { - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()).requireData() - } - } - - @Test - fun throwIfErrorException() { - assertFailsWith { - StoreReadResponse.Error.Exception(Exception(), StoreReadResponseOrigin.Fetcher()).throwIfError() - } - } - - @Test - fun throwIfErrorMessage() { - assertFailsWith { - StoreReadResponse.Error.Message("test error", StoreReadResponseOrigin.Fetcher()).throwIfError() - } - } - - @Test() - fun errorMessageOrNull() { - assertFailsWith(message = Exception::class.toString()) { - StoreReadResponse.Error.Exception(Exception(), StoreReadResponseOrigin.Fetcher()).throwIfError() - } - - assertFailsWith(message = "test error message") { - StoreReadResponse.Error.Message("test error message", StoreReadResponseOrigin.Fetcher()).throwIfError() - } - - assertNull(StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()).errorMessageOrNull()) - } - - @Test - fun swapType() { - assertFailsWith { - StoreReadResponse.Data("Foo", StoreReadResponseOrigin.Fetcher()).swapType() - } - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/StoreWithInMemoryCacheTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/StoreWithInMemoryCacheTests.kt deleted file mode 100644 index 53f442b74..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/StoreWithInMemoryCacheTests.kt +++ /dev/null @@ -1,133 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.test.runTest -import org.mobilenativefoundation.store.core5.ExperimentalStoreApi -import org.mobilenativefoundation.store.store5.impl.extensions.get -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.time.Duration.Companion.hours - -@OptIn(ExperimentalStoreApi::class) -@FlowPreview -@ExperimentalCoroutinesApi -class StoreWithInMemoryCacheTests { - @Test - fun storeRequestsCanCompleteWhenInMemoryCacheWithAccessExpiryIsAtTheMaximumSize() = - runTest { - val store = - StoreBuilder - .from(Fetcher.of { _: Int -> "result" }) - .cachePolicy( - MemoryPolicy - .builder() - .setExpireAfterAccess(1.hours) - .setMaxSize(1) - .build(), - ) - .build() - - val a = store.get(0) - val b = store.get(0) - val c = store.get(1) - val d = store.get(2) - - assertEquals("result", a) - assertEquals("result", b) - assertEquals("result", c) - assertEquals("result", d) - } - - @Test - fun storeDeadlock() = - runTest { - repeat(100) { - val store: MutableStore = - StoreBuilder - .from( - fetcher = Fetcher.of { key: Int -> "fetcher_$key" }, - sourceOfTruth = - SourceOfTruth.of( - reader = { key: Int -> - flowOf("source_of_truth_$key") - }, - writer = { key: Int, local: String -> }, - ), - ) - .disableCache() - .toMutableStoreBuilder( - converter = - object : Converter { - override fun fromNetworkToLocal(network: String): String = network - - override fun fromOutputToLocal(output: String): String = output - }, - ) - .build( - updater = - object : Updater { - var callCount = -1 - - override suspend fun post( - key: Int, - value: String, - ): UpdaterResult { - callCount += 1 - return if (callCount % 2 == 0) { - throw IllegalArgumentException("$key value: $value") - } else { - UpdaterResult.Success.Untyped("") - } - } - - override val onCompletion: OnUpdaterCompletion? = null - }, - ) - - val jobs = mutableListOf() - jobs.add( - store.stream(StoreReadRequest.cached(1, refresh = true)) - .mapNotNull { it.dataOrNull() } - .launchIn(this), - ) - val job1 = - store.stream(StoreReadRequest.cached(0, refresh = true)) - .mapNotNull { it.dataOrNull() } - .launchIn(this) - jobs.add( - store.stream(StoreReadRequest.cached(2, refresh = true)) - .mapNotNull { it.dataOrNull() } - .launchIn(this), - ) - jobs.add( - store.stream(StoreReadRequest.cached(3, refresh = true)) - .mapNotNull { it.dataOrNull() } - .launchIn(this), - ) - job1.cancel() - assertEquals( - expected = "source_of_truth_0", - actual = - store.stream(StoreReadRequest.cached(0, refresh = true)) - .mapNotNull { it.dataOrNull() } - .first(), - ) - jobs.forEach { - it.cancel() - assertEquals( - expected = "source_of_truth_0", - actual = - store.stream(StoreReadRequest.cached(0, refresh = true)) - .mapNotNull { it.dataOrNull() } - .first(), - ) - } - } - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/StreamWithoutSourceOfTruthTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/StreamWithoutSourceOfTruthTests.kt deleted file mode 100644 index a9a81c1b3..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/StreamWithoutSourceOfTruthTests.kt +++ /dev/null @@ -1,129 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import app.cash.turbine.test -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.async -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.take -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.mobilenativefoundation.store.store5.util.FakeFetcher -import kotlin.test.Test -import kotlin.test.assertEquals - -@FlowPreview -@ExperimentalCoroutinesApi -class StreamWithoutSourceOfTruthTests { - private val testScope = TestScope() - - @Test - fun streamWithoutPersisterAndCacheEnabled() = - testScope.runTest { - val fetcher = - FakeFetcher( - 3 to "three-1", - 3 to "three-2", - ) - val pipeline = - StoreBuilder.from(fetcher) - .scope(testScope) - .build() - val twoItemsNoRefresh = - async { - pipeline.stream( - StoreReadRequest.cached(3, refresh = false), - ).take(3).toList() - } - delay(1_000) // make sure the async block starts first - pipeline.stream(StoreReadRequest.fresh(3)).test { - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - assertEquals( - listOf( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - StoreReadResponse.Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - StoreReadResponse.Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - ), - twoItemsNoRefresh.await(), - ) - } - - @Test - fun streamWithoutPersisterAndCacheDisabled() = - testScope.runTest { - val fetcher = - FakeFetcher( - 3 to "three-1", - 3 to "three-2", - ) - val pipeline = - StoreBuilder.from(fetcher) - .scope(testScope) - .disableCache() - .build() - val twoItemsNoRefresh = - async { - pipeline.stream( - StoreReadRequest.cached(3, refresh = false), - ).take(3).toList() - } - delay(1_000) // make sure the async block starts first - pipeline.stream(StoreReadRequest.fresh(3)).test { - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - assertEquals( - listOf( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - StoreReadResponse.Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - StoreReadResponse.Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - ), - twoItemsNoRefresh.await(), - ) - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/UpdaterTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/UpdaterTests.kt deleted file mode 100644 index d7898fee9..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/UpdaterTests.kt +++ /dev/null @@ -1,292 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import app.cash.turbine.test -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.last -import kotlinx.coroutines.flow.take -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.mobilenativefoundation.store.core5.ExperimentalStoreApi -import org.mobilenativefoundation.store.store5.impl.extensions.inHours -import org.mobilenativefoundation.store.store5.util.fake.Notes -import org.mobilenativefoundation.store.store5.util.fake.NotesApi -import org.mobilenativefoundation.store.store5.util.fake.NotesBookkeeping -import org.mobilenativefoundation.store.store5.util.fake.NotesConverterProvider -import org.mobilenativefoundation.store.store5.util.fake.NotesDatabase -import org.mobilenativefoundation.store.store5.util.fake.NotesKey -import org.mobilenativefoundation.store.store5.util.fake.NotesUpdaterProvider -import org.mobilenativefoundation.store.store5.util.fake.NotesValidator -import org.mobilenativefoundation.store.store5.util.model.InputNote -import org.mobilenativefoundation.store.store5.util.model.NetworkNote -import org.mobilenativefoundation.store.store5.util.model.NoteData -import org.mobilenativefoundation.store.store5.util.model.NotesWriteResponse -import org.mobilenativefoundation.store.store5.util.model.OutputNote -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertIs -import kotlin.test.assertNotNull - -@OptIn(ExperimentalCoroutinesApi::class, ExperimentalStoreApi::class) -class UpdaterTests { - private val testScope = TestScope() - private lateinit var api: NotesApi - private lateinit var bookkeeping: NotesBookkeeping - private lateinit var notes: NotesDatabase - - @BeforeTest - fun before() { - api = NotesApi() - bookkeeping = NotesBookkeeping() - notes = NotesDatabase() - } - - @Test - fun givenNonEmptyMarketWhenWriteThenStoredAndAPIUpdated() = - testScope.runTest { - val ttl = inHours(1) - - val converter = NotesConverterProvider().provide() - val validator = NotesValidator() - val updater = NotesUpdaterProvider(api).provide() - val bookkeeper = - Bookkeeper.by( - getLastFailedSync = bookkeeping::getLastFailedSync, - setLastFailedSync = bookkeeping::setLastFailedSync, - clear = bookkeeping::clear, - clearAll = bookkeeping::clear, - ) - - val store = - MutableStoreBuilder.from( - fetcher = Fetcher.of { key -> api.get(key, ttl = ttl) }, - sourceOfTruth = - SourceOfTruth.of( - nonFlowReader = { key -> notes.get(key) }, - writer = { key, sot: InputNote -> notes.put(key, sot) }, - delete = { key -> notes.clear(key) }, - deleteAll = { notes.clear() }, - ), - converter = converter, - ) - .validator(validator) - .build( - updater = updater, - bookkeeper = bookkeeper, - ) - - val readRequest = StoreReadRequest.fresh(NotesKey.Single(Notes.One.id)) - - val stream = store.stream(readRequest) - - // Read is success - stream.test { - assertEquals( - StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher()), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Data( - OutputNote(NoteData.Single(Notes.One), ttl = ttl), - StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - val newNote = Notes.One.copy(title = "New Title-1") - val writeRequest = - StoreWriteRequest.of( - key = NotesKey.Single(Notes.One.id), - value = OutputNote(NoteData.Single(newNote), 0), - ) - - val storeWriteResponse = store.write(writeRequest) - - // Write is success - assertEquals( - StoreWriteResponse.Success.Typed( - NotesWriteResponse( - NotesKey.Single(Notes.One.id), - true, - ), - ), - storeWriteResponse, - ) - - val cachedReadRequest = - StoreReadRequest.cached(NotesKey.Single(Notes.One.id), refresh = false) - val cachedStream = store.stream(cachedReadRequest) - - // Cache + SOT are updated - val firstResponse: StoreReadResponse = cachedStream.first() -// assertEquals( -// StoreReadResponse.Data( -// OutputNote(NoteData.Single(newNote), ttl = 0), -// StoreReadResponseOrigin.Cache -// ), - firstResponse -// ) - - val secondResponse = cachedStream.take(2).last() - assertIs>(secondResponse) - val data: NoteData? = secondResponse.value.data - assertIs(data) - assertNotNull(data) - assertEquals(newNote, data.item) - assertEquals(StoreReadResponseOrigin.SourceOfTruth, secondResponse.origin) - assertNotNull(secondResponse.value.ttl) - - // API is updated - assertEquals( - StoreWriteResponse.Success.Typed( - NotesWriteResponse( - NotesKey.Single(Notes.One.id), - true, - ), - ), - storeWriteResponse, - ) - assertEquals( - NetworkNote(NoteData.Single(newNote), ttl = null), - api.db[NotesKey.Single(Notes.One.id)], - ) - } - - @Test - fun givenNonEmptyMarketWithValidatorWhenInvalidThenSuccessOriginatingFromFetcher() = - testScope.runTest { - val ttl = inHours(1) - - val converter = NotesConverterProvider().provide() - val validator = NotesValidator(expiration = inHours(12)) - val updater = NotesUpdaterProvider(api).provide() - val bookkeeper = - Bookkeeper.by( - getLastFailedSync = bookkeeping::getLastFailedSync, - setLastFailedSync = bookkeeping::setLastFailedSync, - clear = bookkeeping::clear, - clearAll = bookkeeping::clear, - ) - - val store = - MutableStoreBuilder.from( - fetcher = Fetcher.of { key -> api.get(key, ttl = ttl) }, - sourceOfTruth = - SourceOfTruth.of( - nonFlowReader = { key -> notes.get(key) }, - writer = { key, sot: InputNote -> notes.put(key, sot) }, - delete = { key -> notes.clear(key) }, - deleteAll = { notes.clear() }, - ), - converter = converter, - ) - .validator(validator) - .build( - updater = updater, - bookkeeper = bookkeeper, - ) - - val readRequest = StoreReadRequest.fresh(NotesKey.Single(Notes.One.id)) - - val stream = store.stream(readRequest) - - // Fetch is success and validator is not used - stream.test { - assertEquals( - StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher()), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Data( - OutputNote(NoteData.Single(Notes.One), ttl = ttl), - StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - val cachedReadRequest = - StoreReadRequest.cached(NotesKey.Single(Notes.One.id), refresh = false) - val cachedStream = store.stream(cachedReadRequest) - - // Cache + SOT are updated - // But item is invalid - // So we do not emit value in cache or SOT - // Instead we get latest from network even though refresh = false - - cachedStream.test { - assertEquals( - StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher(name = null)), - awaitItem(), - ) - assertEquals( - StoreReadResponse.Data( - OutputNote(NoteData.Single(Notes.One), ttl = ttl), - StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } - - @Test - fun givenEmptyMarketWhenWriteThenSuccessResponsesAndApiUpdated() = - testScope.runTest { - val converter = NotesConverterProvider().provide() - val validator = NotesValidator() - val updater = NotesUpdaterProvider(api).provide() - val bookkeeper = - Bookkeeper.by( - getLastFailedSync = bookkeeping::getLastFailedSync, - setLastFailedSync = bookkeeping::setLastFailedSync, - clear = bookkeeping::clear, - clearAll = bookkeeping::clear, - ) - - val store = - MutableStoreBuilder.from( - fetcher = - Fetcher.ofFlow { key -> - val network = api.get(key) - flow { emit(network) } - }, - sourceOfTruth = - SourceOfTruth.of( - nonFlowReader = { key -> notes.get(key) }, - writer = { key, sot -> notes.put(key, sot) }, - delete = { key -> notes.clear(key) }, - deleteAll = { notes.clear() }, - ), - converter, - ) - .validator(validator) - .build( - updater = updater, - bookkeeper = bookkeeper, - ) - - val newNote = Notes.One.copy(title = "New Title-1") - val writeRequest = - StoreWriteRequest.of( - key = NotesKey.Single(Notes.One.id), - value = OutputNote(NoteData.Single(newNote), 0), - ) - val storeWriteResponse = store.write(writeRequest) - - assertEquals( - StoreWriteResponse.Success.Typed( - NotesWriteResponse( - NotesKey.Single(Notes.One.id), - true, - ), - ), - storeWriteResponse, - ) - assertEquals(NetworkNote(NoteData.Single(newNote)), api.db[NotesKey.Single(Notes.One.id)]) - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/ValueFetcherTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/ValueFetcherTests.kt deleted file mode 100644 index 26af2fb1f..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/ValueFetcherTests.kt +++ /dev/null @@ -1,69 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import app.cash.turbine.test -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals - -@ExperimentalCoroutinesApi -@FlowPreview -class ValueFetcherTests { - private val testScope = TestScope() - - @Test - fun givenValueFetcherWhenInvokeThenResultIsWrapped() = - testScope.runTest { - val fetcher = Fetcher.ofFlow { flowOf(it * it) } - - fetcher(3).test { - assertEquals(FetcherResult.Data(value = 9), awaitItem()) - awaitComplete() - } - } - - @Test - fun givenValueFetcherWhenExceptionInFlowThenExceptionReturnedAsResult() = - testScope.runTest { - val e = Exception() - val fetcher = - Fetcher.ofFlow { - flow { - throw e - } - } - fetcher(3).test { - assertEquals(FetcherResult.Error.Exception(e), awaitItem()) - awaitComplete() - } - } - - @Test - fun givenNonFlowValueFetcherWhenInvokeThenResultIsWrapped() = - testScope.runTest { - val fetcher = Fetcher.of { it * it } - - fetcher(3).test { - assertEquals(FetcherResult.Data(value = 9), awaitItem()) - awaitComplete() - } - } - - @Test - fun givenNonFlowValueFetcherWhenExceptionInFlowThenExceptionReturnedAsResult() = - testScope.runTest { - val e = Exception() - val fetcher = - Fetcher.of { - throw e - } - fetcher(3).test { - assertEquals(FetcherResult.Error.Exception(e), awaitItem()) - awaitComplete() - } - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/MutableStoreConcurrencyTest.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/MutableStoreConcurrencyTest.kt deleted file mode 100644 index 2a2b643ca..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/MutableStoreConcurrencyTest.kt +++ /dev/null @@ -1,126 +0,0 @@ -@file:OptIn(ExperimentalCoroutinesApi::class, ExperimentalStoreApi::class) - -package org.mobilenativefoundation.store.store5.mutablestore - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.test.runTest -import org.mobilenativefoundation.store.cache5.CacheBuilder -import org.mobilenativefoundation.store.core5.ExperimentalStoreApi -import org.mobilenativefoundation.store.store5.StoreWriteRequest -import org.mobilenativefoundation.store.store5.StoreWriteResponse -import org.mobilenativefoundation.store.store5.Updater -import org.mobilenativefoundation.store.store5.UpdaterResult -import org.mobilenativefoundation.store.store5.impl.RealMutableStore -import org.mobilenativefoundation.store.store5.impl.RealStore -import org.mobilenativefoundation.store.store5.mutablestore.util.TestConverter -import org.mobilenativefoundation.store.store5.mutablestore.util.TestFetcher -import org.mobilenativefoundation.store.store5.mutablestore.util.TestLogger -import org.mobilenativefoundation.store.store5.mutablestore.util.TestValidator -import org.mobilenativefoundation.store.store5.mutablestore.util.testStore -import kotlin.test.Test -import kotlin.test.assertTrue - -/** - * Regression test for a data race in [RealMutableStore]'s per-key write-request queue. - * - * The queue is a non-thread-safe `ArrayDeque`. Mutating access goes through - * `withWriteRequestQueueLock`, which historically guarded it with a shared/reader lock that lets - * multiple holders run concurrently. As a result two operations on the same key could run at once: - * `addWriteRequestToQueue` doing `add(...)` while `updateWriteRequestQueue` iterates the same deque - * (`for (writeRequest in this)`). A structural `add` during iteration corrupts the backing array. - * - * On Kotlin/Native this surfaces as `EXC_BAD_ACCESS` (a hard process crash). On the JVM the deque's - * fail-fast iterator throws `ConcurrentModificationException`, which `RealMutableStore` catches and - * converts into a [StoreWriteResponse.Error.Exception]. Either way, with correct mutual exclusion - * every write should succeed. - * - * The delegate is backed by a real thread-safe cache (cache5) with no source of truth, so the only - * unsynchronized shared mutable state exercised here is the write-request queue itself. - */ -@OptIn(ExperimentalCoroutinesApi::class, ExperimentalStoreApi::class) -class MutableStoreConcurrencyTest { - private fun newMutableStore(): RealMutableStore { - val delegate: RealStore = - testStore( - fetcher = TestFetcher(), - sourceOfTruth = null, - converter = TestConverter(), - validator = TestValidator(), - memoryCache = CacheBuilder().build(), - ) - return RealMutableStore( - delegate = delegate, - updater = Updater.by({ _, value -> UpdaterResult.Success.Typed(value) }), - bookkeeper = null, - logger = TestLogger(), - ) - } - - @Test - fun sequentialWritesToSameKey_allSucceed() = - runTest { - val mutableStore = newMutableStore() - val key = "key" - val responses = (1..500).map { i -> mutableStore.write(StoreWriteRequest.of(key = key, value = i)) } - val failures = responses.filterIsInstance() - assertTrue( - failures.isEmpty(), - "Baseline sequential writes should all succeed, but ${failures.size} failed" + - (failures.firstOrNull()?.let { ", first error = ${it.error}" } ?: ""), - ) - } - - @Test - fun concurrentWritesToSameKey_doNotCorruptWriteQueue() = - runTest { - val mutableStore = newMutableStore() - val key = "key" - val concurrentWriters = 64 - val rounds = 50 - - repeat(rounds) { round -> - val responses = - coroutineScope { - (1..concurrentWriters) - .map { i -> - async(Dispatchers.Default) { - mutableStore.write( - StoreWriteRequest.of(key = key, value = round * concurrentWriters + i), - ) - } - } - .awaitAll() - } - - // A corrupted ArrayDeque surfaces as a memory-safety symptom: ConcurrentModificationException, - // NullPointerException, or IndexOutOfBoundsException on the JVM (EXC_BAD_ACCESS aborts the - // process on Native, so reaching this assertion at all already proves no native crash). - // NOTE: concurrent writes to the SAME key can still legitimately fail with - // IllegalArgumentException("No writes found ...") — a separate, pre-existing logical race - // where one write drains another's queue entry. That is not memory corruption and is out of - // scope for this fix, so it is tolerated here. - val corruption = - responses - .filterIsInstance() - .filter { response -> - when (response.error) { - is ConcurrentModificationException, - is NullPointerException, - is IndexOutOfBoundsException, - -> true - else -> false - } - } - assertTrue( - corruption.isEmpty(), - "Write-queue memory corruption in round $round: ${corruption.size}/${responses.size} " + - "writes hit a corruption-class error" + - (corruption.firstOrNull()?.let { ", first = ${it.error}" } ?: ""), - ) - } - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/RealMutableStoreTest.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/RealMutableStoreTest.kt deleted file mode 100644 index 734193b74..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/RealMutableStoreTest.kt +++ /dev/null @@ -1,485 +0,0 @@ -@file:OptIn(ExperimentalCoroutinesApi::class, ExperimentalStoreApi::class) - -package org.mobilenativefoundation.store.store5.mutablestore - -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.async -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.take -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.test.runTest -import org.mobilenativefoundation.store.core5.ExperimentalStoreApi -import org.mobilenativefoundation.store.store5.FetcherResult -import org.mobilenativefoundation.store.store5.SourceOfTruth -import org.mobilenativefoundation.store.store5.StoreReadRequest -import org.mobilenativefoundation.store.store5.StoreReadResponse -import org.mobilenativefoundation.store.store5.StoreWriteRequest -import org.mobilenativefoundation.store.store5.StoreWriteResponse -import org.mobilenativefoundation.store.store5.impl.RealMutableStore -import org.mobilenativefoundation.store.store5.impl.RealStore -import org.mobilenativefoundation.store.store5.mutablestore.util.TestCache -import org.mobilenativefoundation.store.store5.mutablestore.util.TestConverter -import org.mobilenativefoundation.store.store5.mutablestore.util.TestFetcher -import org.mobilenativefoundation.store.store5.mutablestore.util.TestInMemoryBookkeeper -import org.mobilenativefoundation.store.store5.mutablestore.util.TestLogger -import org.mobilenativefoundation.store.store5.mutablestore.util.TestSourceOfTruth -import org.mobilenativefoundation.store.store5.mutablestore.util.TestUpdater -import org.mobilenativefoundation.store.store5.mutablestore.util.TestValidator -import org.mobilenativefoundation.store.store5.mutablestore.util.testStore -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertIs -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue - -private data class Note(val id: String, val content: String) - -private data class NetworkNote(val id: String, val content: String) - -private data class DatabaseNote(val id: String, val content: String) - -@OptIn(ExperimentalCoroutinesApi::class, ExperimentalStoreApi::class) -class RealMutableStoreTest { - private lateinit var testFetcher: TestFetcher - private lateinit var testConverter: TestConverter - private lateinit var testValidator: TestValidator - private lateinit var testSourceOfTruth: TestSourceOfTruth - private lateinit var testCache: TestCache - - private lateinit var testUpdater: TestUpdater - private lateinit var testBookkeeper: TestInMemoryBookkeeper - private lateinit var testLogger: TestLogger - - private lateinit var delegateStore: RealStore - private lateinit var mutableStore: RealMutableStore - - @BeforeTest - fun setUp() { - testFetcher = TestFetcher() - val defaultLocalValue = DatabaseNote("defaultLocalId", "defaultLocalContent") - testConverter = - TestConverter( - defaultNetworkToLocalConverter = { defaultLocalValue }, - defaultOutputToLocalConverter = { defaultLocalValue }, - ) - testValidator = TestValidator() - testSourceOfTruth = TestSourceOfTruth() - testCache = TestCache() - - testFetcher.whenever("key1") { - flowOf(FetcherResult.Data(NetworkNote("networkId", "networkContent"))) - } - - testUpdater = TestUpdater() - testBookkeeper = TestInMemoryBookkeeper() - testLogger = TestLogger() - - delegateStore = - testStore( - fetcher = testFetcher, - sourceOfTruth = testSourceOfTruth, - converter = testConverter, - validator = testValidator, - memoryCache = testCache, - ) - - mutableStore = - RealMutableStore( - delegate = delegateStore, - updater = testUpdater, - bookkeeper = testBookkeeper, - logger = testLogger, - ) - } - - @Test - fun stream_givenNoConflicts_whenReading_thenEmitsFromDelegate() = - runTest { - // Given - val request = StoreReadRequest.Companion.cached("key1", refresh = true) - - // When - val results = mutableStore.stream(request).take(2).toList() - - // Then - assertTrue(results.size >= 2) - assertIs(results[0]) - assertIs>(results[1]) - } - - @Test - fun stream_givenConflictsAndBookkeeper_whenReading_thenAttemptsEagerConflictResolution() = - runTest { - // Given - val request = StoreReadRequest.Companion.cached("key2", refresh = true) - delegateStore.write("key2", Note("localId", "localContent")) - testBookkeeper.setLastFailedSync("key2") - testUpdater.successValue = NetworkNote("resolvedId", "resolvedContent") - - // When - val results = mutableStore.stream(request).take(2).toList() - - // Then - assertTrue(results.isNotEmpty()) - val foundResolutionLog = - testLogger.debugLogs.any { it.contains("resolvedContent") } || - testLogger.debugLogs.any { it.contains("No conflicts.") } - assertTrue(foundResolutionLog, "Expected conflict resolution attempt in debug logs") - assertEquals(null, testBookkeeper.getLastFailedSync("key2")) - assertIs>(results.last()) - } - - @Test - fun stream_givenConflictResolutionFails_whenReading_thenLogsErrorButContinues() = - runTest { - // Given - val request = StoreReadRequest.Companion.cached("key3", refresh = true) - val errorMessage = "Conflict not resolved" - - delegateStore.write("key3", Note("localId3", "localContent3")) - testBookkeeper.setLastFailedSync("key3") - testUpdater.errorMessage = errorMessage - - // When - val results = mutableStore.stream(request).take(2).toList() - - // Then - assertTrue(results.size >= 2) - assertTrue( - testLogger.errorLogs.any { (msg, _) -> msg.contains(errorMessage) }, - "Expected error logs due to conflict resolution failing", - ) - assertNotNull(testBookkeeper.getLastFailedSync("key3")) - } - - @Test - fun stream_givenWriteFlowAndNoConflicts_whenCollecting_thenLocalAndNetworkAreUpdated() = - runTest { - // Given - val requestsFlow = MutableSharedFlow>(replay = 1) - - // When - val responsesDeferred = - async { - mutableStore.stream(requestsFlow).take(1).toList() - } - - requestsFlow.emit( - StoreWriteRequest.Companion.of( - key = "writeKey1", - value = Note("localNoteId1", "localNoteContent1"), - created = 1111L, - onCompletions = null, - ), - ) - - val responses = responsesDeferred.await() - assertTrue(responses.first() is StoreWriteResponse.Success) - - // Then - val read = delegateStore.latestOrNull("writeKey1") - assertEquals("localNoteContent1", read?.content) - assertEquals(null, testBookkeeper.getLastFailedSync("writeKey1")) - } - - @Test - fun stream_givenWriteFlowAndNetworkFailure_whenCollecting_thenLocalIsUpdatedButConflictRemains() = - runTest { - // Given - val requestsFlow = MutableSharedFlow>(replay = 1) - testUpdater.errorMessage = "Network failure" - - // When - val responsesDeferred = - async { - mutableStore.stream(requestsFlow).take(1).toList() - } - - requestsFlow.emit( - StoreWriteRequest.Companion.of( - key = "writeKey2", - value = Note("localNoteId2", "localNoteContent2"), - created = 1111L, - onCompletions = null, - ), - ) - - val responses = responsesDeferred.await() - - // Then - val firstResponse = responses.first() - assertTrue(firstResponse is StoreWriteResponse.Error.Message) - assertTrue(firstResponse.message.contains("Network failure")) - val read = delegateStore.latestOrNull("writeKey2") - assertEquals("localNoteContent2", read?.content) - assertNotNull(testBookkeeper.getLastFailedSync("writeKey2")) - } - - @Test - fun stream_givenMultipleWritesForSameKey_whenAllSucceed_thenOlderRequestsAreClearedFromQueue() = - runTest { - // Given - val requestsFlow = MutableSharedFlow>(replay = 2) - testUpdater.successValue = NetworkNote("someNetId", "someNetContent") - val responsesDeferred = - async { - mutableStore.stream(requestsFlow).take(2).toList() - } - - // When - requestsFlow.emit( - StoreWriteRequest.Companion.of( - key = "multiKey", - value = Note("first", "firstContent"), - created = 100, - onCompletions = null, - ), - ) - requestsFlow.emit( - StoreWriteRequest.Companion.of( - key = "multiKey", - value = Note("second", "secondContent"), - created = 200, - onCompletions = null, - ), - ) - - // Then - val responses = responsesDeferred.await() - assertTrue(responses[0] is StoreWriteResponse.Success) - assertTrue(responses[1] is StoreWriteResponse.Success) - val read = delegateStore.latestOrNull("multiKey") - assertEquals("secondContent", read?.content) - assertNull(testBookkeeper.getLastFailedSync("multiKey")) - } - - @Test - fun write_givenSingleRequestAndNoNetworkIssues_whenCalled_thenSucceeds() = - runTest { - // Given - val request = - StoreWriteRequest.Companion.of( - key = "singleWriteKey", - value = Note("id", "content"), - created = 9999L, - onCompletions = null, - ) - - // When - val response = mutableStore.write(request) - - // Then - assertIs(response) - assertEquals("content", delegateStore.latestOrNull("singleWriteKey")?.content) - } - - @Test - fun write_givenSingleRequestAndNetworkException_whenCalled_thenFailsButLocalUpdated() = - runTest { - // Given - testUpdater.exception = IllegalStateException("Network error!") - val request = - StoreWriteRequest.Companion.of( - key = "exceptionKey", - value = Note("exceptionId", "contentException"), - created = 2222L, - onCompletions = null, - ) - - // When - val response = mutableStore.write(request) - - // Then - assertIs(response) - assertEquals("contentException", delegateStore.latestOrNull("exceptionKey")?.content) - assertNotNull(testBookkeeper.getLastFailedSync("exceptionKey")) - } - - @Test - fun write_givenSourceOfTruthFailure_whenCalled_thenSurfacesWriteError() = - runTest { - // Given - val key = "key" - val errorMessage = "write error" - testSourceOfTruth.throwOnWrite(key) { - IllegalStateException(errorMessage) - } - val request = - StoreWriteRequest.of( - key = key, - value = Note(key, "content"), - created = 3333L, - onCompletions = null, - ) - - // When - val response = mutableStore.write(request) - - // Then - val errorResponse = assertIs(response) - val writeException = assertIs(errorResponse.error) - val cause = assertIs(writeException.cause) - assertEquals(errorMessage, cause.message) - } - - @Test - fun write_givenSourceOfTruthFailure_whenCalled_thenNetworkSyncNotAttempted() = - runTest { - // Given - val key = "key" - testUpdater.postCallCount = 0 - testSourceOfTruth.throwOnWrite(key) { IllegalStateException("SOT failure") } - - val request = - StoreWriteRequest.of( - key = key, - value = Note(key, "content"), - created = 4444L, - onCompletions = null, - ) - - // When - val response = mutableStore.write(request) - - // Then - assertIs(response) - assertEquals(0, testUpdater.postCallCount, "Network updater should not be called when SOT write fails") - } - - @Test - fun write_givenSourceOfTruthFailure_whenCalled_thenMemCacheNotUpdated() = - runTest { - // Given - val key = "key" - testSourceOfTruth.throwOnWrite(key) { IllegalStateException("SOT failure") } - - val request = - StoreWriteRequest.of( - key = key, - value = Note(key, "content"), - created = 6666L, - onCompletions = null, - ) - - // When - val response = mutableStore.write(request) - - // Then - assertIs(response) - assertNull(delegateStore.latestOrNull(key), "Value should not be in cache after SOT write failure") - } - - @Test - fun write_givenNoSourceOfTruth_whenCalled_thenSucceeds() = - runTest { - // Given - val storeWithoutSot = - testStore( - fetcher = testFetcher, - sourceOfTruth = null, - converter = testConverter, - validator = testValidator, - memoryCache = testCache, - ) - val mutableStoreWithoutSot = - RealMutableStore( - delegate = storeWithoutSot, - updater = testUpdater, - bookkeeper = testBookkeeper, - logger = testLogger, - ) - - val request = - StoreWriteRequest.of( - key = "noSotKey", - value = Note("id", "content"), - created = 5555L, - onCompletions = null, - ) - - // When - val response = mutableStoreWithoutSot.write(request) - - // Then - assertIs(response) - } - - @Test - fun clearAll_givenSomeKeys_whenCalled_thenDelegateIsCleared() = - runTest { - // Given - delegateStore.write("clearKey1", Note("id1", "content1")) - delegateStore.write("clearKey2", Note("id2", "content2")) - assertNotNull(delegateStore.latestOrNull("clearKey1")) - assertNotNull(delegateStore.latestOrNull("clearKey2")) - - // When - mutableStore.clear() - - // Then - assertNull(delegateStore.latestOrNull("clearKey1")) - assertNull(delegateStore.latestOrNull("clearKey2")) - } - - @Test - fun clear_givenKey_whenCalled_thenDelegateIsClearedForThatKey() = - runTest { - // Given - delegateStore.write("clearKey", Note("idCleared", "contentCleared")) - assertNotNull(delegateStore.latestOrNull("clearKey")) - - // When - mutableStore.clear("clearKey") - - // Then - assertNull(delegateStore.latestOrNull("clearKey")) - } - - @Test - fun stream_givenNoBookkeeper_whenConflictsMightExistIsCalled_thenNoEagerResolutionIsAttempted() = - runTest { - // Given - val storeNoBookkeeper = - RealMutableStore( - delegate = delegateStore, - updater = testUpdater, - bookkeeper = null, - logger = testLogger, - ) - delegateStore.write("keyNoBook", Note("idNoBook", "contentNoBook")) - val request = StoreReadRequest.Companion.cached("keyNoBook", refresh = false) - - // When - val results = storeNoBookkeeper.stream(request).take(2).toList() - - // Then - assertTrue(results.isNotEmpty()) - assertTrue( - testLogger.debugLogs.none { it.contains("ConflictsResolved") }, - "No conflict resolution logs expected because no Bookkeeper", - ) - } - - @Test - fun write_givenKeyNotInitialized_whenCalled_thenStoreIsSafelyInitialized() = - runTest { - // Given - val request = - StoreWriteRequest.Companion.of( - key = "newKey", - value = Note("someId", "someContent"), - created = 777L, - onCompletions = null, - ) - - // When - val response = mutableStore.write(request) - - // Then - assertIs(response) - assertEquals("someContent", delegateStore.latestOrNull("newKey")?.content) - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestCache.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestCache.kt deleted file mode 100644 index 78d22c91f..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestCache.kt +++ /dev/null @@ -1,76 +0,0 @@ -package org.mobilenativefoundation.store.store5.mutablestore.util - -import org.mobilenativefoundation.store.cache5.Cache - -@Suppress("UNCHECKED_CAST") -class TestCache : Cache { - private val map = HashMap() - var getIfPresentCalls = 0 - var getOrPutCalls = 0 - var getAllPresentCalls = 0 - var putCalls = 0 - var putAllCalls = 0 - var invalidateCalls = 0 - var invalidateAllKeysCalls = 0 - var invalidateAllCalls = 0 - var sizeCalls = 0 - - override fun getIfPresent(key: Key): Value? { - getIfPresentCalls++ - return map[key] - } - - override fun getOrPut( - key: Key, - valueProducer: () -> Value, - ): Value { - getOrPutCalls++ - return map.getOrPut(key, valueProducer) - } - - override fun getAllPresent(keys: List<*>): Map { - getAllPresentCalls++ - return keys.mapNotNull { it as? Key }.associateWithNotNull { key -> map[key] } - } - - override fun put( - key: Key, - value: Value, - ) { - putCalls++ - map[key] = value - } - - override fun putAll(map: Map) { - putAllCalls++ - map.forEach { (k, v) -> put(k, v) } - } - - override fun invalidate(key: Key) { - invalidateCalls++ - map.remove(key) - } - - override fun invalidateAll(keys: List) { - invalidateAllKeysCalls++ - keys.forEach { map.remove(it) } - } - - override fun invalidateAll() { - invalidateAllCalls++ - map.clear() - } - - override fun size(): Long { - sizeCalls++ - return map.size.toLong() - } - - private inline fun Iterable.associateWithNotNull(transform: (K) -> V?): Map { - val destination = mutableMapOf() - for (element in this) { - transform(element)?.let { destination[element] = it } - } - return destination - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestConverter.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestConverter.kt deleted file mode 100644 index 845dcd439..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestConverter.kt +++ /dev/null @@ -1,34 +0,0 @@ -package org.mobilenativefoundation.store.store5.mutablestore.util - -import org.mobilenativefoundation.store.store5.Converter - -@Suppress("UNCHECKED_CAST") -class TestConverter( - private val defaultNetworkToLocalConverter: ((Network) -> Local)? = null, - private val defaultOutputToLocalConverter: ((Output) -> Local)? = null, -) : Converter { - private val networkToLocalMap: HashMap = HashMap() - private val outputToLocalMap: HashMap = HashMap() - - fun wheneverNetwork( - network: Network, - block: () -> Local, - ) { - networkToLocalMap[network] = block() - } - - fun wheneverOutput( - output: Output, - block: () -> Local, - ) { - outputToLocalMap[output] = block() - } - - override fun fromNetworkToLocal(network: Network): Local { - return networkToLocalMap[network] ?: defaultNetworkToLocalConverter?.invoke(network) ?: network as Local - } - - override fun fromOutputToLocal(output: Output): Local { - return outputToLocalMap[output] ?: defaultOutputToLocalConverter?.invoke(output) ?: output as Local - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestFetcher.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestFetcher.kt deleted file mode 100644 index a074424a4..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestFetcher.kt +++ /dev/null @@ -1,25 +0,0 @@ -package org.mobilenativefoundation.store.store5.mutablestore.util - -import kotlinx.coroutines.flow.Flow -import org.mobilenativefoundation.store.store5.Fetcher -import org.mobilenativefoundation.store.store5.FetcherResult - -class TestFetcher( - override val name: String? = null, - override val fallback: Fetcher? = null, -) : Fetcher { - private val faked = HashMap>>() - - fun whenever( - key: Key, - block: () -> Flow>, - ) { - faked[key] = block() - } - - override operator fun invoke(key: Key): Flow> { - return requireNotNull(faked[key]) { - "No fetcher result provided for key=$key" - } - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestInMemoryBookkeeper.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestInMemoryBookkeeper.kt deleted file mode 100644 index 1c159c81e..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestInMemoryBookkeeper.kt +++ /dev/null @@ -1,28 +0,0 @@ -package org.mobilenativefoundation.store.store5.mutablestore.util - -import org.mobilenativefoundation.store.store5.Bookkeeper - -class TestInMemoryBookkeeper : Bookkeeper { - private val failedSyncMap = mutableMapOf() - - override suspend fun getLastFailedSync(key: Key): Long? { - return failedSyncMap[key] - } - - override suspend fun setLastFailedSync( - key: Key, - timestamp: Long, - ): Boolean { - failedSyncMap[key] = timestamp - return true - } - - override suspend fun clear(key: Key): Boolean { - return failedSyncMap.remove(key) != null - } - - override suspend fun clearAll(): Boolean { - failedSyncMap.clear() - return true - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestLogger.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestLogger.kt deleted file mode 100644 index 332ea0023..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestLogger.kt +++ /dev/null @@ -1,19 +0,0 @@ -package org.mobilenativefoundation.store.store5.mutablestore.util - -import org.mobilenativefoundation.store.store5.Logger - -class TestLogger : Logger { - val debugLogs = mutableListOf() - val errorLogs = mutableListOf>() - - override fun debug(message: String) { - debugLogs.add(message) - } - - override fun error( - message: String, - throwable: Throwable?, - ) { - errorLogs.add(message to throwable) - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestSourceOfTruth.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestSourceOfTruth.kt deleted file mode 100644 index 3b569e8d0..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestSourceOfTruth.kt +++ /dev/null @@ -1,67 +0,0 @@ -package org.mobilenativefoundation.store.store5.mutablestore.util - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.flow -import org.mobilenativefoundation.store.store5.SourceOfTruth - -@Suppress("UNCHECKED_CAST") -class TestSourceOfTruth : SourceOfTruth { - private val storage = HashMap() - private val flows = HashMap>() - private var readError: Throwable? = null - private var writeError: Throwable? = null - private var deleteError: Throwable? = null - private var deleteAllError: Throwable? = null - - fun throwOnRead( - key: Key, - block: () -> Throwable, - ) { - readError = block() - } - - fun throwOnWrite( - key: Key, - block: () -> Throwable, - ) { - writeError = block() - } - - fun throwOnDelete( - key: Key?, - block: () -> Throwable, - ) { - if (key != null) deleteError = block() else deleteAllError = block() - } - - override fun reader(key: Key): Flow = - flow { - readError?.let { throw SourceOfTruth.ReadException(key, it) } - val sharedFlow = flows.getOrPut(key) { MutableSharedFlow(replay = 1) } - emit(storage[key] as Output?) - emitAll(sharedFlow) - } - - override suspend fun write( - key: Key, - value: Local, - ) { - writeError?.let { throw SourceOfTruth.WriteException(key, value, it) } - storage[key] = value - flows[key]?.emit(value as Output?) - } - - override suspend fun delete(key: Key) { - deleteError?.let { throw it } - storage.remove(key) - flows.remove(key) - } - - override suspend fun deleteAll() { - deleteAllError?.let { throw it } - storage.clear() - flows.clear() - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestStore.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestStore.kt deleted file mode 100644 index 62504297c..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestStore.kt +++ /dev/null @@ -1,29 +0,0 @@ -package org.mobilenativefoundation.store.store5.mutablestore.util - -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import org.mobilenativefoundation.store.cache5.Cache -import org.mobilenativefoundation.store.store5.Converter -import org.mobilenativefoundation.store.store5.Fetcher -import org.mobilenativefoundation.store.store5.SourceOfTruth -import org.mobilenativefoundation.store.store5.Validator -import org.mobilenativefoundation.store.store5.impl.RealStore - -internal fun testStore( - dispatcher: CoroutineDispatcher = Dispatchers.Default, - scope: CoroutineScope = CoroutineScope(dispatcher), - fetcher: Fetcher = TestFetcher(), - sourceOfTruth: SourceOfTruth? = TestSourceOfTruth(), - converter: Converter = TestConverter(), - validator: Validator = TestValidator(), - memoryCache: Cache = TestCache(), -): RealStore = - RealStore( - scope = scope, - fetcher = fetcher, - sourceOfTruth = sourceOfTruth, - converter = converter, - validator = validator, - memCache = memoryCache, - ) diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestUpdater.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestUpdater.kt deleted file mode 100644 index f83dc8936..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestUpdater.kt +++ /dev/null @@ -1,25 +0,0 @@ -package org.mobilenativefoundation.store.store5.mutablestore.util - -import org.mobilenativefoundation.store.store5.OnUpdaterCompletion -import org.mobilenativefoundation.store.store5.Updater -import org.mobilenativefoundation.store.store5.UpdaterResult - -class TestUpdater : Updater { - var exception: Throwable? = null - var errorMessage: String? = null - var successValue: Response? = null - var postCallCount: Int = 0 - - override suspend fun post( - key: Key, - value: Output, - ): UpdaterResult { - postCallCount++ - exception?.let { return UpdaterResult.Error.Exception(it) } - errorMessage?.let { return UpdaterResult.Error.Message(it) } - successValue?.let { return UpdaterResult.Success.Typed(it) } - return UpdaterResult.Success.Untyped(value) - } - - override val onCompletion: OnUpdaterCompletion? = null -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestValidator.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestValidator.kt deleted file mode 100644 index c6e852429..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestValidator.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.mobilenativefoundation.store.store5.mutablestore.util - -import org.mobilenativefoundation.store.store5.Validator - -class TestValidator : Validator { - private val map: HashMap = HashMap() - - fun whenever( - item: Output, - block: () -> Boolean, - ) { - map[item] = block() - } - - override suspend fun isValid(item: Output): Boolean { - return map[item] != false - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/AsFlowable.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/AsFlowable.kt deleted file mode 100644 index 60d8bb9ca..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/AsFlowable.kt +++ /dev/null @@ -1,132 +0,0 @@ -package org.mobilenativefoundation.store.store5.util - -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import org.mobilenativefoundation.store.store5.SourceOfTruth - -/** - * Only used in FlowStoreTest. We should get rid of it eventually. - */ -class SimplePersisterAsFlowable( - private val reader: suspend (Key) -> Output?, - private val writer: suspend (Key, Output) -> Unit, - private val delete: (suspend (Key) -> Unit)? = null, -) { - val supportsDelete: Boolean - get() = delete != null - - private val versionTracker = KeyTracker() - - fun flowReader(key: Key): Flow = - flow { - versionTracker.keyFlow(key).collect { - emit(reader(key)) - } - } - - suspend fun flowWriter( - key: Key, - value: Output, - ) { - writer(key, value) - versionTracker.invalidate(key) - } - - suspend fun flowDelete(key: Key) { - delete?.let { - it(key) - versionTracker.invalidate(key) - } - } -} - -fun SimplePersisterAsFlowable.asSourceOfTruth() = - SourceOfTruth.of( - reader = ::flowReader, - writer = ::flowWriter, - delete = ::flowDelete.takeIf { supportsDelete }, - ) - -/** - * helper class which provides Flows for Keys that can be tracked. - */ -internal class KeyTracker { - private val lock = Mutex() - - // list of open key flows - private val flows = mutableMapOf() - - // for testing - internal fun activeKeyCount() = flows.size - - /** - * invalidates the given key. If there are flows returned from [keyFlow] for the given [key], - * they'll receive a new emission - */ - suspend fun invalidate(key: Key) { - lock.withLock { - flows[key] - }?.flow?.emit(Unit) - } - - /** - * Returns a Flow that emits once and then every time the given [key] is invalidated via - * [invalidate] - */ - suspend fun keyFlow(key: Key): Flow { - // it is important to allocate KeyFlow lazily (ony when the returned flow is collected - // from). Otherwise, we might just create many of them that are never observed hence never - // cleaned up - return flow { - val keyFlow = - lock.withLock { - flows.getOrPut(key) { KeyFlow() }.also { - it.acquire() - } - } - emit(Unit) - try { - emitAll(keyFlow.flow) - } finally { - withContext(NonCancellable) { - lock.withLock { - if (keyFlow.release()) { - flows.remove(key) - } - } - } - } - } - } - - /** - * A data structure to count how many active flows we have on this flow - */ - private class KeyFlow { - val flow = - MutableSharedFlow( - extraBufferCapacity = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) - private var collectors: Int = 0 - - fun acquire() { - collectors++ - } - - fun release() = (--collectors) == 0 - } -} - -fun InMemoryPersister.asFlowable() = - SimplePersisterAsFlowable( - reader = this::read, - writer = this::write, - ) diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/FakeFetcher.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/FakeFetcher.kt deleted file mode 100644 index dd8c88efe..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/FakeFetcher.kt +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.mobilenativefoundation.store.store5.util - -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf -import org.mobilenativefoundation.store.store5.Fetcher -import org.mobilenativefoundation.store.store5.FetcherResult -import kotlin.test.assertEquals - -class FakeFetcher( - private vararg val responses: Pair, -) : Fetcher { - private var index = 0 - override val name: String? = null - override val fallback: Fetcher? = null - - override fun invoke(key: Key): Flow> { - if (index >= responses.size) { - throw AssertionError("unexpected fetch request") - } - val pair = responses[index++] - assertEquals(pair.first, key) - return flowOf(FetcherResult.Data(pair.second)) - } -} - -class FakeFlowingFetcher( - private vararg val responses: Pair, -) : Fetcher { - override val name: String? = null - override val fallback: Fetcher? = null - - override fun invoke(key: Key) = - flow { - responses.filter { - it.first == key - }.forEach { - // we delay here to avoid collapsing fetcher values, otherwise, there is a - // possibility that consumer won't be fast enough to get both values before new - // value overrides the previous one. - delay(1) - emit(FetcherResult.Data(it.second)) - } - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/InMemoryPersister.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/InMemoryPersister.kt deleted file mode 100644 index 0ba8d1f32..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/InMemoryPersister.kt +++ /dev/null @@ -1,49 +0,0 @@ -package org.mobilenativefoundation.store.store5.util - -import org.mobilenativefoundation.store.store5.SourceOfTruth - -/** - * An in-memory non-flowing persister for testing. - */ -open class InMemoryPersister { - private val data = mutableMapOf() - var preWriteCallback: (suspend (key: Key, value: Output) -> Output)? = null - var postReadCallback: (suspend (key: Key, value: Output?) -> Output?)? = null - - @Suppress("RedundantSuspendModifier") // for function reference - suspend fun read(key: Key): Output? { - val value = data[key] - postReadCallback?.let { - return it(key, value) - } - return value - } - - @Suppress("RedundantSuspendModifier") // for function reference - open suspend fun write(key: Key, output: Output) { - val value = preWriteCallback?.invoke(key, output) ?: output - data[key] = value - } - - @Suppress("RedundantSuspendModifier") // for function reference - suspend fun deleteByKey(key: Key) { - data.remove(key) - } - - @Suppress("RedundantSuspendModifier") // for function reference - suspend fun deleteAll() { - data.clear() - } - - fun peekEntry(key: Key): Output? { - return data[key] - } -} - -fun InMemoryPersister.asSourceOfTruth() = - SourceOfTruth.of( - nonFlowReader = ::read, - writer = ::write, - delete = ::deleteByKey, - deleteAll = ::deleteAll, - ) diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/TestApi.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/TestApi.kt deleted file mode 100644 index 778021e9d..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/TestApi.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.mobilenativefoundation.store.store5.util - -internal interface TestApi { - fun get( - key: Key, - fail: Boolean = false, - ttl: Long? = null, - ): Network? - - fun post( - key: Key, - value: Output, - fail: Boolean = false, - ): Response -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/TestStoreExt.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/TestStoreExt.kt deleted file mode 100644 index 87e3f1679..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/TestStoreExt.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.mobilenativefoundation.store.store5.util - -import kotlinx.coroutines.flow.filterNot -import kotlinx.coroutines.flow.first -import org.mobilenativefoundation.store.store5.Store -import org.mobilenativefoundation.store.store5.StoreReadRequest -import org.mobilenativefoundation.store.store5.StoreReadResponse -import org.mobilenativefoundation.store.store5.impl.operators.mapIndexed - -/** - * Helper factory that will return [StoreReadResponse.Data] for [key] - * if it is cached otherwise will return fresh/network data (updating your caches) - */ -suspend fun Store.getData(key: Key) = - stream( - StoreReadRequest.cached(key, refresh = false), - ).filterNot { - it is StoreReadResponse.Loading - }.mapIndexed { index, value -> - value - }.first().let { - StoreReadResponse.Data(it.requireData(), it.origin) - } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NoteCollections.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NoteCollections.kt deleted file mode 100644 index 8482cff6f..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NoteCollections.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.mobilenativefoundation.store.store5.util.fake - -import org.mobilenativefoundation.store.store5.util.model.NoteData - -internal object NoteCollections { - object Keys { - const val OneAndTwo = "ONE_AND_TWO" - } - - val OneAndTwo = NoteData.Collection(listOf(Notes.One, Notes.Two)) -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/Notes.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/Notes.kt deleted file mode 100644 index cfc4c6e75..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/Notes.kt +++ /dev/null @@ -1,16 +0,0 @@ -package org.mobilenativefoundation.store.store5.util.fake - -import org.mobilenativefoundation.store.store5.util.model.Note - -internal object Notes { - val One = Note("1", "Title-1", "Content-1") - val Two = Note("2", "Title-2", "Content-2") - val Three = Note("3", "Title-3", "Content-3") - val Four = Note("4", "Title-4", "Content-4") - val Five = Note("5", "Title-5", "Content-5") - val Six = Note("6", "Title-6", "Content-6") - val Seven = Note("7", "Title-7", "Content-7") - val Eight = Note("8", "Title-8", "Content-8") - val Nine = Note("9", "Title-9", "Content-9") - val Ten = Note("10", "Title-10", "Content-10") -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesApi.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesApi.kt deleted file mode 100644 index f7de9e21b..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesApi.kt +++ /dev/null @@ -1,60 +0,0 @@ -package org.mobilenativefoundation.store.store5.util.fake - -import org.mobilenativefoundation.store.store5.util.TestApi -import org.mobilenativefoundation.store.store5.util.model.InputNote -import org.mobilenativefoundation.store.store5.util.model.NetworkNote -import org.mobilenativefoundation.store.store5.util.model.NoteData -import org.mobilenativefoundation.store.store5.util.model.NotesWriteResponse - -internal class NotesApi : TestApi { - internal val db = mutableMapOf() - - init { - seed() - } - - override fun get( - key: NotesKey, - fail: Boolean, - ttl: Long?, - ): NetworkNote { - if (fail) { - throw Exception() - } - - val networkNote = db[key]!! - return if (ttl != null) { - networkNote.copy(ttl = ttl) - } else { - networkNote - } - } - - override fun post( - key: NotesKey, - value: InputNote, - fail: Boolean, - ): NotesWriteResponse { - if (fail) { - throw Exception() - } - - db[key] = NetworkNote(value.data) - - return NotesWriteResponse(key, true) - } - - private fun seed() { - db[NotesKey.Single(Notes.One.id)] = NetworkNote(NoteData.Single(Notes.One)) - db[NotesKey.Single(Notes.Two.id)] = NetworkNote(NoteData.Single(Notes.Two)) - db[NotesKey.Single(Notes.Three.id)] = NetworkNote(NoteData.Single(Notes.Three)) - db[NotesKey.Single(Notes.Four.id)] = NetworkNote(NoteData.Single(Notes.Four)) - db[NotesKey.Single(Notes.Five.id)] = NetworkNote(NoteData.Single(Notes.Five)) - db[NotesKey.Single(Notes.Six.id)] = NetworkNote(NoteData.Single(Notes.Six)) - db[NotesKey.Single(Notes.Seven.id)] = NetworkNote(NoteData.Single(Notes.Seven)) - db[NotesKey.Single(Notes.Eight.id)] = NetworkNote(NoteData.Single(Notes.Eight)) - db[NotesKey.Single(Notes.Nine.id)] = NetworkNote(NoteData.Single(Notes.Nine)) - db[NotesKey.Single(Notes.Ten.id)] = NetworkNote(NoteData.Single(Notes.Ten)) - db[NotesKey.Collection(NoteCollections.Keys.OneAndTwo)] = NetworkNote(NoteCollections.OneAndTwo) - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesBookkeeping.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesBookkeeping.kt deleted file mode 100644 index 61c342820..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesBookkeeping.kt +++ /dev/null @@ -1,47 +0,0 @@ -package org.mobilenativefoundation.store.store5.util.fake - -class NotesBookkeeping { - private val log: MutableMap = mutableMapOf() - - fun setLastFailedSync( - key: NotesKey, - timestamp: Long, - fail: Boolean = false, - ): Boolean { - if (fail) { - throw Exception() - } - log[key] = timestamp - return true - } - - fun getLastFailedSync( - key: NotesKey, - fail: Boolean = false, - ): Long? { - if (fail) { - throw Exception() - } - - return log[key] - } - - fun clear( - key: NotesKey, - fail: Boolean = false, - ): Boolean { - if (fail) { - throw Exception() - } - log.remove(key) - return true - } - - fun clear(fail: Boolean = false): Boolean { - if (fail) { - throw Exception() - } - log.clear() - return true - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesConverterProvider.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesConverterProvider.kt deleted file mode 100644 index ee89216da..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesConverterProvider.kt +++ /dev/null @@ -1,20 +0,0 @@ -package org.mobilenativefoundation.store.store5.util.fake - -import org.mobilenativefoundation.store.store5.Converter -import org.mobilenativefoundation.store.store5.impl.extensions.inHours -import org.mobilenativefoundation.store.store5.util.model.InputNote -import org.mobilenativefoundation.store.store5.util.model.NetworkNote -import org.mobilenativefoundation.store.store5.util.model.OutputNote - -internal class NotesConverterProvider { - fun provide(): Converter = - Converter.Builder() - .fromOutputToLocal { value -> InputNote(data = value.data, ttl = value.ttl) } - .fromNetworkToLocal { value: NetworkNote -> - InputNote( - data = value.data, - ttl = value.ttl ?: inHours(12), - ) - } - .build() -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesDatabase.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesDatabase.kt deleted file mode 100644 index 063a0a3a9..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesDatabase.kt +++ /dev/null @@ -1,51 +0,0 @@ -package org.mobilenativefoundation.store.store5.util.fake - -import org.mobilenativefoundation.store.store5.util.model.InputNote -import org.mobilenativefoundation.store.store5.util.model.OutputNote - -internal class NotesDatabase { - private val db: MutableMap = mutableMapOf() - - fun put( - key: NotesKey, - input: InputNote, - fail: Boolean = false, - ): Boolean { - if (fail) { - throw Exception() - } - - db[key] = OutputNote(input.data, input.ttl ?: 0) - return true - } - - fun get( - key: NotesKey, - fail: Boolean = false, - ): OutputNote? { - if (fail) { - throw Exception() - } - - return db[key] - } - - fun clear( - key: NotesKey, - fail: Boolean = false, - ): Boolean { - if (fail) { - throw Exception() - } - db.remove(key) - return true - } - - fun clear(fail: Boolean = false): Boolean { - if (fail) { - throw Exception() - } - db.clear() - return true - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesKey.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesKey.kt deleted file mode 100644 index 25058d3b6..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesKey.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.mobilenativefoundation.store.store5.util.fake - -sealed class NotesKey { - data class Single(val id: String) : NotesKey() - - data class Collection(val id: String) : NotesKey() -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesUpdaterProvider.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesUpdaterProvider.kt deleted file mode 100644 index 35529028e..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesUpdaterProvider.kt +++ /dev/null @@ -1,21 +0,0 @@ -package org.mobilenativefoundation.store.store5.util.fake - -import org.mobilenativefoundation.store.store5.Updater -import org.mobilenativefoundation.store.store5.UpdaterResult -import org.mobilenativefoundation.store.store5.util.model.InputNote -import org.mobilenativefoundation.store.store5.util.model.NotesWriteResponse -import org.mobilenativefoundation.store.store5.util.model.OutputNote - -internal class NotesUpdaterProvider(private val api: NotesApi) { - fun provide(): Updater = - Updater.by( - post = { key, input -> - val response = api.post(key, InputNote(input.data, input.ttl ?: 0)) - if (response.ok) { - UpdaterResult.Success.Typed(response) - } else { - UpdaterResult.Error.Message("Failed to sync") - } - }, - ) -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesValidator.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesValidator.kt deleted file mode 100644 index bf7296f22..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesValidator.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.mobilenativefoundation.store.store5.util.fake - -import org.mobilenativefoundation.store.store5.Validator -import org.mobilenativefoundation.store.store5.impl.extensions.now -import org.mobilenativefoundation.store.store5.util.model.OutputNote - -internal class NotesValidator(private val expiration: Long = now()) : Validator { - override suspend fun isValid(item: OutputNote): Boolean = - when { - item.ttl == 0L -> true - else -> item.ttl > expiration - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/HardcodedPages.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/HardcodedPages.kt deleted file mode 100644 index d7c7c9441..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/HardcodedPages.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.mobilenativefoundation.store.store5.util.fake.fallback - -class HardcodedPages { - val name = "HardcodedPages" - val db = mutableMapOf() - - init { - seed() - } - - private fun seed() { - db["1"] = Page.Data("1") - db["2"] = Page.Data("2") - db["3"] = Page.Data("3") - } - - fun get(key: String) = db[key] ?: throw Exception() -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/Page.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/Page.kt deleted file mode 100644 index ccc456277..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/Page.kt +++ /dev/null @@ -1,10 +0,0 @@ -package org.mobilenativefoundation.store.store5.util.fake.fallback - -sealed class Page { - data class Data( - val title: String, - val ttl: Long? = null, - ) : Page() - - object Empty : Page() -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/PagesDatabase.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/PagesDatabase.kt deleted file mode 100644 index 3c7a0f245..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/PagesDatabase.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.mobilenativefoundation.store.store5.util.fake.fallback - -class PagesDatabase { - private val db: MutableMap = mutableMapOf() - - fun put( - key: String, - input: Page, - ): Boolean { - db[key] = input - return true - } - - fun get(key: String): Page? = db[key] -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/PrimaryPagesApi.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/PrimaryPagesApi.kt deleted file mode 100644 index 7c8126702..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/PrimaryPagesApi.kt +++ /dev/null @@ -1,29 +0,0 @@ -package org.mobilenativefoundation.store.store5.util.fake.fallback - -class PrimaryPagesApi { - val name = "PrimaryPagesApi" - - internal val db = mutableMapOf() - - init { - seed() - } - - private fun seed() { - db["1"] = Page.Data("1") - db["2"] = Page.Data("2") - db["3"] = Page.Data("3") - } - - fun fetch( - key: String, - fail: Boolean, - ttl: Long?, - ): Page { - if (fail) { - throw Exception() - } - - return db[key] ?: Page.Empty - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/SecondaryPagesApi.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/SecondaryPagesApi.kt deleted file mode 100644 index 59c3fb398..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/SecondaryPagesApi.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.mobilenativefoundation.store.store5.util.fake.fallback - -class SecondaryPagesApi() { - val name: String = "SecondaryPagesApi" - internal val db = mutableMapOf() - - init { - seed() - } - - fun get(key: String) = db[key] ?: throw Exception() - - private fun seed() { - db["1"] = Page.Data("1") - db["2"] = Page.Data("2") - db["3"] = Page.Data("3") - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/model/NoteData.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/model/NoteData.kt deleted file mode 100644 index cce0fc5fa..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/model/NoteData.kt +++ /dev/null @@ -1,35 +0,0 @@ -package org.mobilenativefoundation.store.store5.util.model - -import org.mobilenativefoundation.store.store5.util.fake.NotesKey - -internal sealed class NoteData { - data class Single(val item: Note) : NoteData() - - data class Collection(val items: List) : NoteData() -} - -internal data class NotesWriteResponse( - val key: NotesKey, - val ok: Boolean, -) - -internal data class NetworkNote( - val data: NoteData? = null, - val ttl: Long? = null, -) - -internal data class InputNote( - val data: NoteData? = null, - val ttl: Long? = null, -) - -internal data class OutputNote( - val data: NoteData? = null, - val ttl: Long, -) - -internal data class Note( - val id: String, - val title: String, - val content: String, -) diff --git a/store/src/iosMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.ios.kt b/store/src/iosMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.ios.kt deleted file mode 100644 index 585ab1dc7..000000000 --- a/store/src/iosMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.ios.kt +++ /dev/null @@ -1,16 +0,0 @@ -package org.mobilenativefoundation.store.store5.impl.extensions - -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.alloc -import kotlinx.cinterop.memScoped -import kotlinx.cinterop.ptr -import platform.posix.gettimeofday -import platform.posix.timeval - -@OptIn(ExperimentalForeignApi::class) -internal actual fun currentTimeMillis(): Long = - memScoped { - val tv = alloc() - gettimeofday(tv.ptr, null) - (tv.tv_sec.toLong() * 1000L) + (tv.tv_usec.toLong() / 1000L) - } diff --git a/store/src/jsMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.js.kt b/store/src/jsMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.js.kt deleted file mode 100644 index 5ed24ef65..000000000 --- a/store/src/jsMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.js.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.mobilenativefoundation.store.store5.impl.extensions - -@Suppress("UnsafeCastFromDynamic") -internal actual fun currentTimeMillis(): Long = - kotlin.js.Date - .now() - .toLong() diff --git a/store/src/jvmMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.jvm.kt b/store/src/jvmMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.jvm.kt deleted file mode 100644 index 08d6e9b49..000000000 --- a/store/src/jvmMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.jvm.kt +++ /dev/null @@ -1,3 +0,0 @@ -package org.mobilenativefoundation.store.store5.impl.extensions - -internal actual fun currentTimeMillis(): Long = System.currentTimeMillis() diff --git a/store/src/linuxX64Main/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.linuxX64.kt b/store/src/linuxX64Main/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.linuxX64.kt deleted file mode 100644 index 585ab1dc7..000000000 --- a/store/src/linuxX64Main/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.linuxX64.kt +++ /dev/null @@ -1,16 +0,0 @@ -package org.mobilenativefoundation.store.store5.impl.extensions - -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.alloc -import kotlinx.cinterop.memScoped -import kotlinx.cinterop.ptr -import platform.posix.gettimeofday -import platform.posix.timeval - -@OptIn(ExperimentalForeignApi::class) -internal actual fun currentTimeMillis(): Long = - memScoped { - val tv = alloc() - gettimeofday(tv.ptr, null) - (tv.tv_sec.toLong() * 1000L) + (tv.tv_usec.toLong() / 1000L) - } diff --git a/store/src/wasmJsMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.wasmJs.kt b/store/src/wasmJsMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.wasmJs.kt deleted file mode 100644 index a73410eb4..000000000 --- a/store/src/wasmJsMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.wasmJs.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.mobilenativefoundation.store.store5.impl.extensions - -@JsFun("() => Date.now()") -private external fun dateNow(): Double - -internal actual fun currentTimeMillis(): Long = dateNow().toLong() diff --git a/store6-benchmarks/README.md b/store6-benchmarks/README.md new file mode 100644 index 000000000..c6a67aa6d --- /dev/null +++ b/store6-benchmarks/README.md @@ -0,0 +1,116 @@ +# store6-benchmarks + +`store6-benchmarks` is an unpublished JVM harness for Store v6. It measures +end-to-end collector attachment plus write-to-final-observation under +a controlled schedule against the raw Source of Truth flow, and records +structural-plus-measured evidence for telemetry unset versus configured-noop +overhead. It is neither a published artifact nor a public API. + +## Run + +From the repository root: + +```shell +./gradlew :store6-benchmarks:benchmark +./gradlew :store6-benchmarks:smokeBenchmark +./gradlew :store6-benchmarks:calibrateBenchmark +``` + +`benchmark` is the default local profile. `smokeBenchmark` is the short, +report-only CI shape. `calibrateBenchmark` uses three forks and is the only +profile whose results may support a performance-target proposal when run on a +documented, quiet, plugged-in machine. + +Result JSON is discovered recursively beneath +`store6-benchmarks/build/reports/benchmarks/`. The timestamped layout observed +with `kotlinx-benchmark` 0.4.17 is evidence, not a stable path contract. + +Start from a clean report directory. From the repository root, this snippet +requires exactly one non-empty, structurally valid JSON result before +summarizing it. It sorts parameter names before rendering them. + +```shell +reports_dir="store6-benchmarks/build/reports/benchmarks" +report_count="$( + find "$reports_dir" -type f -name '*.json' -print | + wc -l | + tr -d ' ' +)" +test "$report_count" -eq 1 +report="$(find "$reports_dir" -type f -name '*.json' -print)" +jq -e ' + type == "array" and + length > 0 and + all(.[]; + (.benchmark | type == "string" and length > 0) and + (.primaryMetric.score | type == "number") and + (.primaryMetric.scoreUnit | type == "string" and length > 0) + ) +' "$report" >/dev/null +jq -r ' + .[] | + [ + .benchmark, + ((.params // {}) | + to_entries | + sort_by(.key) | + map("\(.key)=\(.value)") | + join(",")), + (.primaryMetric.score | tostring), + .primaryMetric.scoreUnit + ] | + @tsv +' "$report" +``` + +Treat a clean invocation as valid only when it produces one non-empty JSON +array with the expected benchmark inventory and numeric primary metrics. + +## What the numbers mean + +1. **The measured ratio.** The metric is the `storeStream` / `rawSotFlow` + average-time ratio for an end-to-end timed invocation. Within each invocation, W=1000 + begins only after every collector receives a public result and observes one + epoch-unique readiness-marker write. That precondition is outside W but + inside the timed operation. The score includes collector launch, attachment, + readiness, the W schedule, and final observation. It is not pure W-only + latency or per-emission cost. Both sides may conflate intermediate writes. + `paced=true` cooperatively yields the writer; it is not an acknowledgement or + a guarantee that all writes are observed. `paced=false` is + burst/conflation. +2. **Headline and topology boundary.** `collectors=1` is the engine-overhead + headline because both sides use `FakeSourceOfTruth` with matching reader + multiplicity and common write cost. `collectors=8` is fan-out/topology data: + raw opens eight reader chains while Store shares one upstream and fans out. + It does not isolate engine overhead. +3. **Dispatch hops count.** Store's `Dispatchers.Default` engine hops are part + of Store cost. The raw side cooperates on `runBlocking`. +4. **The telemetry-off zero-overhead claim is structural plus measured.** + Structural tests and code establish that unset telemetry remains null and + allocates no fetch mark. + `none`-vs-`noop` estimates incremental configured-noop overhead relative to + unset: non-null branches, the mark, and virtual no-op calls. It does not prove + literal zero cost or bound total machinery against a telemetry-free engine. + The ABBA allocation probe covers only the caller-thread resident path; a + local JMH GC profiler, when available, covers cross-thread allocations. +5. **Hosted CI is smoke-grade.** No hosted number may support a performance + target. Only a local quiet-machine `calibrate` result may support a target + proposal. +6. **No numeric CI gate exists.** Until a numeric performance target is + adopted, workflows validate execution and schema only. They contain no + performance threshold. +7. **Invocation isolation.** Every multi-write stream invocation uses + epoch-unique readiness and sentinel values. Stores close per thread-scoped + trial; cold stores close per invocation. + +## CI boundary + +The blocking `:store6-benchmarks:build` step in +`.github/workflows/store6.yml` compiles the harness and executes every benchmark +body once through smoke tests. It is a rot guard, not a performance gate. + +`.github/workflows/store6-benchmarks.yml` runs `smokeBenchmark`, validates the +result shape, and uploads the JSON in a non-blocking, report-only measurement +lane. It remains outside the exact-head-green release gate. No workflow may +assert a timing or allocation threshold until a numeric performance target is +adopted. diff --git a/store6-benchmarks/build.gradle.kts b/store6-benchmarks/build.gradle.kts new file mode 100644 index 000000000..5fe1a5688 --- /dev/null +++ b/store6-benchmarks/build.gradle.kts @@ -0,0 +1,61 @@ +plugins { + id("org.jetbrains.kotlin.jvm") + id("org.jetbrains.kotlin.plugin.allopen") version libs.versions.baseKotlin.get() + alias(libs.plugins.kotlinx.benchmark) +} + +kotlin { jvmToolchain(11) } + +// JMH requires @State classes to be non-final. The kotlinx.benchmark annotations typealias to +// JMH's on the JVM target, so allopen keys on the JMH FQN (kotlinx-benchmark README, Kotlin/JVM +// setup). Benchmark classes are additionally declared `open` for clarity. +allOpen { + annotation("org.openjdk.jmh.annotations.State") +} + +dependencies { + implementation(projects.store6Core) + // FakeSourceOfTruth: the shared, contract-kit-passing SoT on BOTH sides of every ratio. + implementation(projects.store6Testing) + implementation(libs.kotlinx.benchmark.runtime) + testImplementation(kotlin("test")) +} + +benchmark { + configurations { + named("main") { + warmups = 5 + iterations = 10 + iterationTime = 1 + iterationTimeUnit = "s" + mode = "avgt" + outputTimeUnit = "us" + } + // Fast, report-only signal; never a hard performance gate. + register("smoke") { + warmups = 2 + iterations = 3 + iterationTime = 500 + iterationTimeUnit = "ms" + mode = "avgt" + outputTimeUnit = "us" + } + // Longer calibration profile for an otherwise quiet machine. + register("calibrate") { + warmups = 8 + iterations = 15 + iterationTime = 2 + iterationTimeUnit = "s" + mode = "avgt" + outputTimeUnit = "us" + advanced("jvmForks", "3") + } + } + targets { + register("main") { + this as kotlinx.benchmark.gradle.JvmBenchmarkTarget + // PIN: JMH backend pinned for reproducibility. + jmhVersion = "1.37" + } + } +} diff --git a/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/BenchKey.kt b/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/BenchKey.kt new file mode 100644 index 000000000..6a24e0802 --- /dev/null +++ b/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/BenchKey.kt @@ -0,0 +1,12 @@ +package org.mobilenativefoundation.store6.benchmarks + +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace + +internal val BENCH_NAMESPACE = StoreNamespace("bench") + +internal class BenchKey(private val id: String) : StoreKey { + override val namespace: StoreNamespace = BENCH_NAMESPACE + + override fun canonicalId(): String = id +} diff --git a/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/ColdStartBenchmark.kt b/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/ColdStartBenchmark.kt new file mode 100644 index 000000000..7e1bd60d2 --- /dev/null +++ b/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/ColdStartBenchmark.kt @@ -0,0 +1,51 @@ +package org.mobilenativefoundation.store6.benchmarks + +import kotlinx.benchmark.Benchmark +import kotlinx.benchmark.Blackhole +import kotlinx.benchmark.Scope +import kotlinx.benchmark.Setup +import kotlinx.benchmark.State +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.store +import org.mobilenativefoundation.store6.testing.FakeSourceOfTruth + +/** + * Supplementary: quickstart-shape spin-up. The store side deliberately includes builder cost, + * engine construction, and close() per invocation — that is the measurand (what a fresh + * store-per-screen pattern would pay). The raw side is a fresh reader collection's first row. + * Not part of the METRIC-1 headline ratio. + */ +@OptIn(ExperimentalStoreApi::class) +@State(Scope.Thread) +open class ColdStartBenchmark { + private lateinit var sot: FakeSourceOfTruth + private val key = BenchKey("cold") + + @Setup + fun setup() { + sot = FakeSourceOfTruth() + runBlocking { sot.write(key, "seed") } + } + + @Benchmark + fun storeColdConstructAndFirstData(bh: Blackhole) = runBlocking { + val store = store { + fetcher { error("unreachable: all reads use Freshness.LocalOnly") } + persistence(sot) + } + try { + bh.consume(store.stream(key, Freshness.LocalOnly).first { it is StoreResult.Data }) + } finally { + store.close() + } + } + + @Benchmark + fun rawColdFirstRead(bh: Blackhole) = runBlocking { + bh.consume(sot.reader(key).first()) + } +} diff --git a/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/GetPathBenchmark.kt b/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/GetPathBenchmark.kt new file mode 100644 index 000000000..cf1bc688a --- /dev/null +++ b/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/GetPathBenchmark.kt @@ -0,0 +1,56 @@ +package org.mobilenativefoundation.store6.benchmarks + +import kotlinx.benchmark.Benchmark +import kotlinx.benchmark.Blackhole +import kotlinx.benchmark.Scope +import kotlinx.benchmark.Setup +import kotlinx.benchmark.State +import kotlinx.benchmark.TearDown +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.store +import org.mobilenativefoundation.store6.testing.FakeSourceOfTruth + +/** + * Supplementary: the one-shot resident read path. Between invocations the key quiesces, so ops + * exercise the resident/idle-revive path (maxIdleKeys default 128 keeps the engine parked, never + * destroyed) — stated in the first-data doc alongside the number. + */ +@OptIn(ExperimentalStoreApi::class) +@State(Scope.Thread) +open class GetPathBenchmark { + private lateinit var sot: FakeSourceOfTruth + private lateinit var store: Store + private val key = BenchKey("get") + + @Setup + fun setup() { + sot = FakeSourceOfTruth() + store = store { + fetcher { error("unreachable: all reads use Freshness.LocalOnly") } + persistence(sot) + } + runBlocking { + sot.write(key, "seed") + store.get(key, Freshness.LocalOnly) + } + } + + @TearDown + fun tearDown() { + store.close() + } + + @Benchmark + fun storeGetResident(bh: Blackhole) = runBlocking { + bh.consume(store.get(key, Freshness.LocalOnly)) + } + + @Benchmark + fun rawReaderFirst(bh: Blackhole) = runBlocking { + bh.consume(sot.reader(key).first()) + } +} diff --git a/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/NoopTelemetry.kt b/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/NoopTelemetry.kt new file mode 100644 index 000000000..86f9fe8da --- /dev/null +++ b/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/NoopTelemetry.kt @@ -0,0 +1,14 @@ +package org.mobilenativefoundation.store6.benchmarks + +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.seam.StoreTelemetry + +/** + * Configured-but-empty sink. Every hook keeps its interface-default no-op body. Comparing a store + * built with this sink to one with telemetry unset estimates incremental configured-noop overhead + * relative to the null fast path: non-null branches, the fetch-duration mark in + * KeyEngine.launchFetch, and virtual dispatch into empty bodies. + */ +@OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) +internal object NoopTelemetry : StoreTelemetry diff --git a/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/StreamEmissionBenchmark.kt b/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/StreamEmissionBenchmark.kt new file mode 100644 index 000000000..8bd6055bd --- /dev/null +++ b/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/StreamEmissionBenchmark.kt @@ -0,0 +1,149 @@ +package org.mobilenativefoundation.store6.benchmarks + +import kotlinx.benchmark.Benchmark +import kotlinx.benchmark.Blackhole +import kotlinx.benchmark.Param +import kotlinx.benchmark.Scope +import kotlinx.benchmark.Setup +import kotlinx.benchmark.State +import kotlinx.benchmark.TearDown +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.store +import org.mobilenativefoundation.store6.testing.FakeSourceOfTruth + +/** + * METRIC-1: stream-emission overhead versus the raw SoT flow. + * + * Both sides observe the SAME FakeSourceOfTruth class under the SAME write schedule, awaiting the + * SAME epoch-unique sentinel. Within each timed invocation, before workload writes, every + * collector first receives a public emission and then observes an epoch-unique attachment marker. + * That marker is one additional common write/observation per invocation outside the W=1000 + * workload but inside the timed operation. It is an attachment precondition, not workload data, + * and proves the Store's long-lived reader/fan-out pipeline is attached before W begins. + * + * With collectors=1, reader multiplicity matches and storeStream/rawSotFlow is the METRIC-1 + * engine-overhead headline: registry, reader pipeline, planning, conflation, projection, telemetry + * null-guard, and dispatch hops. The engine runs on Dispatchers.Default while the raw side is + * cooperative on the runBlocking thread; that asymmetry is Store cost and stays in the ratio. + * + * collectors=8 is a separately interpreted fan-out/topology cell: raw opens eight FakeSourceOfTruth + * reader chains while Store shares one upstream and fans out. It is useful end-to-end scaling data, + * not an isolated engine-overhead ratio. + * + * The reported score includes collector launch, attachment, readiness, the W schedule, and final + * observation. It is an end-to-end attach-plus-schedule measurand, not pure W-only latency or + * per-emission unit cost. Both sides may conflate arbitrary intermediate writes. paced=true is a + * cooperatively yielded writer schedule, not an acknowledgement or per-emission guarantee; + * paced=false is the burst/conflation schedule. The two bracket real workloads. + */ +@OptIn(ExperimentalStoreApi::class) +@State(Scope.Thread) +open class StreamEmissionBenchmark { + @Param("1000") + var writes: Int = 0 + + @Param("1", "8") + var collectors: Int = 0 + + @Param("false", "true") + var paced: Boolean = false + + private lateinit var sot: FakeSourceOfTruth + private lateinit var store: Store + private val key = BenchKey("stream") + private var epoch = 0L + + @Setup + fun setup() { + sot = FakeSourceOfTruth() + store = store { + fetcher { error("unreachable: all reads use Freshness.LocalOnly") } + persistence(sot) + } + runBlocking { sot.write(key, "seed") } + } + + @TearDown + fun tearDown() { + store.close() + } + + @Benchmark + fun rawSotFlow(bh: Blackhole) = runBlocking { + epoch += 1 + val readiness = "ready-$epoch" + val sentinel = "v-$epoch-$writes" + coroutineScope { + val initialReadies = List(collectors) { CompletableDeferred() } + val attachedReadies = List(collectors) { CompletableDeferred() } + repeat(collectors) { c -> + launch { + var first = true + bh.consume( + sot.reader(key).first { + if (first) { + first = false + initialReadies[c].complete(Unit) + } + if (it == readiness) attachedReadies[c].complete(Unit) + it == sentinel + }, + ) + } + } + initialReadies.forEach { it.await() } + sot.write(key, readiness) + attachedReadies.forEach { it.await() } + runSchedule() + } + } + + @Benchmark + fun storeStream(bh: Blackhole) = runBlocking { + epoch += 1 + val readiness = "ready-$epoch" + val sentinel = "v-$epoch-$writes" + coroutineScope { + val initialReadies = List(collectors) { CompletableDeferred() } + val attachedReadies = List(collectors) { CompletableDeferred() } + repeat(collectors) { c -> + launch { + var first = true + bh.consume( + store.stream(key, Freshness.LocalOnly).first { + if (first) { + first = false + initialReadies[c].complete(Unit) + } + if (it is StoreResult.Data && it.value == readiness) { + attachedReadies[c].complete(Unit) + } + it is StoreResult.Data && it.value == sentinel + }, + ) + } + } + initialReadies.forEach { it.await() } + sot.write(key, readiness) + attachedReadies.forEach { it.await() } + runSchedule() + } + } + + /** The identical write schedule both benchmark methods run after all collectors attach. */ + private suspend fun runSchedule() { + for (i in 1..writes) { + sot.write(key, "v-$epoch-$i") + if (paced) yield() + } + } +} diff --git a/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/SubscriptionChurnBenchmark.kt b/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/SubscriptionChurnBenchmark.kt new file mode 100644 index 000000000..494272c9b --- /dev/null +++ b/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/SubscriptionChurnBenchmark.kt @@ -0,0 +1,54 @@ +package org.mobilenativefoundation.store6.benchmarks + +import kotlinx.benchmark.Benchmark +import kotlinx.benchmark.Blackhole +import kotlinx.benchmark.Scope +import kotlinx.benchmark.Setup +import kotlinx.benchmark.State +import kotlinx.benchmark.TearDown +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.store +import org.mobilenativefoundation.store6.testing.FakeSourceOfTruth + +/** + * Supplementary: attach -> first Data -> cancel against a long-lived store, repeatedly. This is + * the registry/reader-pipeline lifecycle that READER_PIPELINE_GRACE_MILLIS parks between + * collections. The raw side is the same churn against the bare reader. + */ +@OptIn(ExperimentalStoreApi::class) +@State(Scope.Thread) +open class SubscriptionChurnBenchmark { + private lateinit var sot: FakeSourceOfTruth + private lateinit var store: Store + private val key = BenchKey("churn") + + @Setup + fun setup() { + sot = FakeSourceOfTruth() + store = store { + fetcher { error("unreachable: all reads use Freshness.LocalOnly") } + persistence(sot) + } + runBlocking { sot.write(key, "seed") } + } + + @TearDown + fun tearDown() { + store.close() + } + + @Benchmark + fun storeAttachFirstDataCancel(bh: Blackhole) = runBlocking { + bh.consume(store.stream(key, Freshness.LocalOnly).first { it is StoreResult.Data }) + } + + @Benchmark + fun rawAttachFirstRowCancel(bh: Blackhole) = runBlocking { + bh.consume(sot.reader(key).first { it != null }) + } +} diff --git a/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/TelemetryOverheadBenchmark.kt b/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/TelemetryOverheadBenchmark.kt new file mode 100644 index 000000000..1625a81c4 --- /dev/null +++ b/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/TelemetryOverheadBenchmark.kt @@ -0,0 +1,123 @@ +package org.mobilenativefoundation.store6.benchmarks + +import kotlinx.benchmark.Benchmark +import kotlinx.benchmark.Blackhole +import kotlinx.benchmark.Param +import kotlinx.benchmark.Scope +import kotlinx.benchmark.Setup +import kotlinx.benchmark.State +import kotlinx.benchmark.TearDown +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.store +import org.mobilenativefoundation.store6.testing.FakeSourceOfTruth + +/** + * The measured half of the telemetry "zero cost when unset" claim; the allocation-count half is + * TelemetryAllocationProbe (see StoreTelemetryTest.kt:114). + * + * This evidence is measured plus structural, not a literal differential against a telemetry-free + * engine. Structural inspection and tests establish that telemetry=none leaves the install point + * null, each call site takes its null fast path, and KeyEngine.launchFetch allocates no + * fetch-duration mark. telemetry=noop installs NoopTelemetry, so this benchmark estimates the + * incremental configured-noop overhead relative to that unset/null fast path: non-null branches, + * the fetch-duration mark, and virtual calls into no-op bodies. There is no seam-less engine to + * compare, so the delta neither proves literal zero cost nor bounds total telemetry machinery cost + * relative to such an engine. + * + * fetchGet: full fetch cycle per op (onFetchStarted + mark + onFetchSucceeded + onServe), via + * MustBeFresh against a constant fetcher on the DSL-default in-memory SoT (public builder path). + * residentServe: resident LocalOnly get (onServe only). streamEmissions: each timed invocation + * launches one collector, waits for its first public result and an epoch-unique readiness marker, + * then runs a 100-write cooperatively yielded schedule through the attached stream. Its score + * includes that precondition, the schedule, and final observation. Both variants may conflate + * intermediate writes, and onServe runs once per public delivery. The none/noop pair uses the same + * schedule. + */ +@OptIn(ExperimentalStoreApi::class) +@State(Scope.Thread) +open class TelemetryOverheadBenchmark { + @Param("none", "noop") + var telemetry: String = "none" + + private lateinit var sot: FakeSourceOfTruth + private lateinit var fetchStore: Store + private lateinit var localStore: Store + private val key = BenchKey("telemetry") + private var epoch = 0L + + @Setup + fun setup() { + sot = FakeSourceOfTruth() + fetchStore = store { + fetcher { "fetched" } + if (telemetry == "noop") telemetry(NoopTelemetry) + } + localStore = store { + fetcher { error("unreachable: all localStore reads use Freshness.LocalOnly") } + persistence(sot) + if (telemetry == "noop") telemetry(NoopTelemetry) + } + runBlocking { + sot.write(key, "seed") + localStore.get(key, Freshness.LocalOnly) + } + } + + @TearDown + fun tearDown() { + fetchStore.close() + localStore.close() + } + + @Benchmark + fun fetchGet(bh: Blackhole) = runBlocking { + bh.consume(fetchStore.get(key, Freshness.MustBeFresh)) + } + + @Benchmark + fun residentServe(bh: Blackhole) = runBlocking { + bh.consume(localStore.get(key, Freshness.LocalOnly)) + } + + @Benchmark + fun streamEmissions(bh: Blackhole) = runBlocking { + epoch += 1 + val readiness = "ready-$epoch" + val sentinel = "v-$epoch-100" + coroutineScope { + val initialReady = CompletableDeferred() + val attachedReady = CompletableDeferred() + launch { + var first = true + bh.consume( + localStore.stream(key, Freshness.LocalOnly).first { + if (first) { + first = false + initialReady.complete(Unit) + } + if (it is StoreResult.Data && it.value == readiness) { + attachedReady.complete(Unit) + } + it is StoreResult.Data && it.value == sentinel + }, + ) + } + initialReady.await() + sot.write(key, readiness) + attachedReady.await() + for (i in 1..100) { + sot.write(key, "v-$epoch-$i") + yield() + } + } + } +} diff --git a/store6-benchmarks/src/test/kotlin/org/mobilenativefoundation/store6/benchmarks/HarnessSmokeTest.kt b/store6-benchmarks/src/test/kotlin/org/mobilenativefoundation/store6/benchmarks/HarnessSmokeTest.kt new file mode 100644 index 000000000..dcc547fa0 --- /dev/null +++ b/store6-benchmarks/src/test/kotlin/org/mobilenativefoundation/store6/benchmarks/HarnessSmokeTest.kt @@ -0,0 +1,95 @@ +package org.mobilenativefoundation.store6.benchmarks + +import kotlinx.benchmark.Blackhole +import kotlin.test.Test + +/** + * Executes every benchmark method once with tiny parameters, without the JMH runner. This test is + * what makes the blocking `:store6-benchmarks:build` CI step a rot guard: benchmark code cannot + * silently decay while the measurement lane stays non-blocking. Numbers are not read here. + */ +class HarnessSmokeTest { + // JMH's sanctioned escape hatch for constructing a Blackhole outside the runner; the string is + // JMH API (org.openjdk.jmh.infra.Blackhole's guarded constructor). + private val bh = Blackhole( + "Today's password is swordfish. I understand instantiating Blackholes directly is dangerous.", + ) + + @Test + fun streamEmissionBenchmark_bothSides_runOnce() { + val b = StreamEmissionBenchmark() + b.writes = 8 + b.collectors = 2 + b.paced = true + b.setup() + try { + b.rawSotFlow(bh) + b.storeStream(bh) + } finally { + b.tearDown() + } + } + + @Test + fun streamEmissionBenchmark_burstRegime_runsOnce() { + val b = StreamEmissionBenchmark() + b.writes = 8 + b.collectors = 1 + b.paced = false + b.setup() + try { + b.rawSotFlow(bh) + b.storeStream(bh) + } finally { + b.tearDown() + } + } + + @Test + fun coldStartBenchmark_bothSides_runOnce() { + val b = ColdStartBenchmark() + b.setup() + b.storeColdConstructAndFirstData(bh) + b.rawColdFirstRead(bh) + } + + @Test + fun getPathBenchmark_bothSides_runOnce() { + val b = GetPathBenchmark() + b.setup() + try { + b.storeGetResident(bh) + b.rawReaderFirst(bh) + } finally { + b.tearDown() + } + } + + @Test + fun subscriptionChurnBenchmark_bothSides_runOnce() { + val b = SubscriptionChurnBenchmark() + b.setup() + try { + b.storeAttachFirstDataCancel(bh) + b.rawAttachFirstRowCancel(bh) + } finally { + b.tearDown() + } + } + + @Test + fun telemetryOverheadBenchmark_bothVariants_runOnce() { + for (variant in listOf("none", "noop")) { + val b = TelemetryOverheadBenchmark() + b.telemetry = variant + b.setup() + try { + b.fetchGet(bh) + b.residentServe(bh) + b.streamEmissions(bh) + } finally { + b.tearDown() + } + } + } +} diff --git a/store6-benchmarks/src/test/kotlin/org/mobilenativefoundation/store6/benchmarks/TelemetryAllocationProbe.kt b/store6-benchmarks/src/test/kotlin/org/mobilenativefoundation/store6/benchmarks/TelemetryAllocationProbe.kt new file mode 100644 index 000000000..dcf29b8e8 --- /dev/null +++ b/store6-benchmarks/src/test/kotlin/org/mobilenativefoundation/store6/benchmarks/TelemetryAllocationProbe.kt @@ -0,0 +1,173 @@ +package org.mobilenativefoundation.store6.benchmarks + +import kotlinx.coroutines.runBlocking +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.store +import org.mobilenativefoundation.store6.testing.FakeSourceOfTruth +import java.lang.management.ManagementFactory +import kotlin.test.Test + +/** + * Allocation evidence for the measured-plus-structural zero-cost-when-unset claim: this module + * performs the allocation-count measurement StoreTelemetryTest.kt:114 references. Reports + * CALLER-THREAD allocated bytes/op on the resident-serve path for telemetry-unset vs + * NoopTelemetry-configured stores. + * + * REPORT-ONLY by design: prints a table, asserts nothing numeric (no threshold is defined yet), and + * skips gracefully off HotSpot. Known scope limit, stated wherever the numbers are quoted: the + * fetch-duration mark allocates on the ENGINE thread (KeyEngine.launchFetch), so + * a caller-thread probe cannot see it — the JMH none-vs-noop timing deltas and the optional local + * `-prof gc` run cover the full cross-thread path. + */ +class TelemetryAllocationProbe { + @OptIn(ExperimentalStoreApi::class) + @Test + fun residentServe_callerThreadAllocationDelta_reported() { + val mx = ManagementFactory.getThreadMXBean() + if (mx !is com.sun.management.ThreadMXBean || !mx.isThreadAllocatedMemorySupported) { + println("TelemetryAllocationProbe: thread-allocation measurement unsupported on this JVM; skipping.") + return + } + + val allocatedMemoryWasEnabled = mx.isThreadAllocatedMemoryEnabled + try { + mx.isThreadAllocatedMemoryEnabled = true + runAbbaProbe(mx) + } finally { + mx.isThreadAllocatedMemoryEnabled = allocatedMemoryWasEnabled + } + } + + @OptIn(ExperimentalStoreApi::class) + private fun runAbbaProbe(mx: com.sun.management.ThreadMXBean) { + val warmupOps = 20_000 + val measuredOps = 100_000 + val key = BenchKey("alloc-probe") + val unsetSot = FakeSourceOfTruth() + val unsetStore = store { + fetcher { error("unreachable: LocalOnly") } + persistence(unsetSot) + } + val samples: AbbaSamples? = try { + val noopSot = FakeSourceOfTruth() + val noopStore = store { + fetcher { error("unreachable: LocalOnly") } + persistence(noopSot) + telemetry(NoopTelemetry) + } + try { + runBlocking { + unsetSot.write(key, "seed") + noopSot.write(key, "seed") + repeat(warmupOps) { unsetStore.get(key, Freshness.LocalOnly) } + repeat(warmupOps) { noopStore.get(key, Freshness.LocalOnly) } + + val tid = Thread.currentThread().id + val unsetFirst = measureSamplePerOp( + mx = mx, + tid = tid, + store = unsetStore, + key = key, + measuredOps = measuredOps, + label = "unset A", + ) ?: return@runBlocking null + val noopFirst = measureSamplePerOp( + mx = mx, + tid = tid, + store = noopStore, + key = key, + measuredOps = measuredOps, + label = "noop A", + ) ?: return@runBlocking null + val noopSecond = measureSamplePerOp( + mx = mx, + tid = tid, + store = noopStore, + key = key, + measuredOps = measuredOps, + label = "noop B", + ) ?: return@runBlocking null + val unsetSecond = measureSamplePerOp( + mx = mx, + tid = tid, + store = unsetStore, + key = key, + measuredOps = measuredOps, + label = "unset B", + ) ?: return@runBlocking null + AbbaSamples( + unsetFirst = unsetFirst, + unsetSecond = unsetSecond, + noopFirst = noopFirst, + noopSecond = noopSecond, + ) + } + } finally { + noopStore.close() + } + } finally { + unsetStore.close() + } + + if (samples == null) return + val unsetMean = (samples.unsetFirst + samples.unsetSecond) / 2 + val noopMean = (samples.noopFirst + samples.noopSecond) / 2 + println("TelemetryAllocationProbe (resident LocalOnly get, caller-thread bytes/op; warmed ABBA):") + println(" telemetry unset samples : ${samples.unsetFirst}, ${samples.unsetSecond} B/op") + println(" NoopTelemetry samples : ${samples.noopFirst}, ${samples.noopSecond} B/op") + println(" telemetry unset mean : $unsetMean B/op") + println(" NoopTelemetry mean : $noopMean B/op") + println(" aggregate delta (noop-unset): ${noopMean - unsetMean} B/op") + } + + private suspend fun measureSamplePerOp( + mx: com.sun.management.ThreadMXBean, + tid: Long, + store: Store, + key: BenchKey, + measuredOps: Int, + label: String, + ): Long? { + if (Thread.currentThread().id != tid) { + println( + "TelemetryAllocationProbe: allocation measurement inconclusive/unsupported: " + + "caller thread changed before $label.", + ) + return null + } + val before = mx.getThreadAllocatedBytes(tid) + if (before < 0) { + println( + "TelemetryAllocationProbe: allocation measurement inconclusive/unsupported: " + + "$label returned before=$before.", + ) + return null + } + repeat(measuredOps) { store.get(key, Freshness.LocalOnly) } + if (Thread.currentThread().id != tid) { + println( + "TelemetryAllocationProbe: allocation measurement inconclusive/unsupported: " + + "caller thread changed during $label.", + ) + return null + } + val after = mx.getThreadAllocatedBytes(tid) + if (after < 0 || after < before) { + println( + "TelemetryAllocationProbe: allocation measurement inconclusive/unsupported: " + + "$label returned before=$before, after=$after.", + ) + return null + } + return (after - before) / measuredOps + } + + private data class AbbaSamples( + val unsetFirst: Long, + val unsetSecond: Long, + val noopFirst: Long, + val noopSecond: Long, + ) +} diff --git a/store6-compose-demo/build.gradle.kts b/store6-compose-demo/build.gradle.kts new file mode 100644 index 000000000..76cc1d34d --- /dev/null +++ b/store6-compose-demo/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("org.jetbrains.kotlin.jvm") + alias(libs.plugins.kotlin.compose.compiler) + alias(libs.plugins.jetbrains.compose) +} + +kotlin { jvmToolchain(11) } + +val store6StabilityConfig = + rootProject.layout.projectDirectory.file("store6-compose/stability/store6-stability.conf") + +composeCompiler { + stabilityConfigurationFiles.add(store6StabilityConfig) + metricsDestination.set(layout.buildDirectory.dir("compose-metrics")) + reportsDestination.set(layout.buildDirectory.dir("compose-reports")) +} + +// The CI compose-stability gate reads the reports emitted below, so they must always describe +// the current sources and conf. Two Compose-plugin gaps otherwise break that: +// 1. stabilityConfigurationFiles is not registered as a task input, so a conf-only edit leaves +// compileKotlin UP-TO-DATE against stale settings — fixed by inputs.file below. +// 2. the reports are an undeclared side-effect output, so a build-cache hit (org.gradle.caching +// is on, and CI restores the cache) skips the compiler and leaves whatever report happens to +// be on disk — which would make the gate assert against a stale file. Opting these tiny +// compilations out of the cache keeps report-on-disk == inputs-on-disk; UP-TO-DATE still +// applies, and an up-to-date task's report was written by the last real execution with +// exactly these inputs. +tasks.withType().configureEach { + inputs.file(store6StabilityConfig).withPathSensitivity(PathSensitivity.RELATIVE) + outputs.cacheIf { false } +} + +dependencies { + implementation(projects.store6Compose) + implementation(projects.store6Testing) + implementation(compose.desktop.currentOs) + implementation(compose.material3) + testImplementation(kotlin("test")) + testImplementation(libs.kotlinx.coroutines.test) +} + +compose.desktop { + application { mainClass = "org.mobilenativefoundation.store6.composedemo.MainKt" } +} diff --git a/store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/DemoScreen.kt b/store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/DemoScreen.kt new file mode 100644 index 000000000..cf1320001 --- /dev/null +++ b/store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/DemoScreen.kt @@ -0,0 +1,106 @@ +@file:OptIn(ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.composedemo + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.mobilenativefoundation.store6.compose.collectAsState +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreResult + +@Composable +fun DemoScreen(store: Store, controls: DemoControls) { + val scope = rememberCoroutineScope() + val key = remember { UserKey("1") } + val result by store.collectAsState(key) + var lastData by remember { mutableStateOf?>(null) } + val current = result + val ui = deriveDemoUiState(current, lastData) + // Retain the last Data for the next composition. Assigning during composition would be a + // backwards write to state this composition already read; SideEffect defers it until the + // composition has successfully applied. + if (current is StoreResult.Data) { + SideEffect { lastData = current } + } + + MaterialTheme { + Column( + Modifier.fillMaxSize().padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Box(Modifier.fillMaxWidth().height(180.dp)) { + val data = ui.card + when { + data != null -> Card(Modifier.fillMaxSize()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(data.value.name, style = MaterialTheme.typography.headlineSmall) + Text("origin=${data.origin} refreshing=${data.refreshing}") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (ui.showStaleBadge) { + AssistChip(onClick = {}, label = { Text("STALE") }) + } + ui.errorBanner?.let { banner -> + Text(banner, color = MaterialTheme.colorScheme.error) + } + } + } + } + ui.showLoadingPlaceholder -> Text("Loading…") + ui.emptyError != null -> Text( + "Failed with no local value: ${ui.emptyError}", + color = MaterialTheme.colorScheme.error, + ) + else -> {} + } + if (ui.showSpinner) { + CircularProgressIndicator(Modifier.align(Alignment.TopEnd).size(28.dp)) + } + } + + val latency by controls.latencyMillis.collectAsState() + Text("Fetch latency: ${latency}ms") + Slider( + value = latency.toFloat(), + onValueChange = { controls.latencyMillis.value = it.toLong() }, + valueRange = 0f..5000f, + ) + val failing by controls.failFetches.collectAsState() + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Fail fetches") + Switch(checked = failing, onCheckedChange = { controls.failFetches.value = it }) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { scope.launch { store.invalidate(key) } }) { Text("Invalidate") } + Button(onClick = { scope.launch { store.clear(key) } }) { Text("Clear") } + } + } + } +} diff --git a/store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/DemoStore.kt b/store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/DemoStore.kt new file mode 100644 index 000000000..2f4ee4b52 --- /dev/null +++ b/store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/DemoStore.kt @@ -0,0 +1,53 @@ +@file:OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) + +package org.mobilenativefoundation.store6.composedemo + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.seam.Fetcher +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import org.mobilenativefoundation.store6.testing.FakeFetcher + +class UserKey(val id: String) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("users") + + override fun canonicalId(): String = id +} + +data class User(val id: String, val name: String) + +/** Live knobs the demo screen mutates while the store keeps fetching. */ +class DemoControls { + val latencyMillis = MutableStateFlow(1500L) + val failFetches = MutableStateFlow(false) +} + +/** + * Toggleable latency/failure around a store6-testing [FakeFetcher]: scripted results win, + * otherwise a deterministic versioned user is produced so every refetch visibly changes. + */ +class DemoFetcher( + private val controls: DemoControls, + val delegate: FakeFetcher = FakeFetcher(), +) : Fetcher { + private var version = 0 + + init { + delegate.onUnscripted = { key, _ -> + version += 1 + FetcherResult.Success(User(key.id, "User ${key.id} (v$version)")) + } + } + + override suspend fun fetch(key: UserKey, etag: String?): FetcherResult { + delay(controls.latencyMillis.value) + if (controls.failFetches.value) { + return FetcherResult.Error(IllegalStateException("Demo failure toggle is on")) + } + return delegate.fetch(key, etag) + } +} diff --git a/store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/DemoUiState.kt b/store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/DemoUiState.kt new file mode 100644 index 000000000..718fcce79 --- /dev/null +++ b/store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/DemoUiState.kt @@ -0,0 +1,44 @@ +@file:OptIn(ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.composedemo + +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreResult + +/** Everything the demo screen shows, derived purely from (current result, retained data). */ +data class DemoUiState( + /** Content card: the current Data, else the last Data retained across refresh/error. */ + val card: StoreResult.Data?, + /** Spinner over content: refreshing Data, or Loading while retained content exists. */ + val showSpinner: Boolean, + /** STALE badge on the card. */ + val showStaleBadge: Boolean, + /** Error banner over retained content; null when no error or no content to banner over. */ + val errorBanner: String?, + /** Error with no local value at all (full-surface error state). */ + val emptyError: StoreError?, + /** Initial Loading with nothing to show yet. */ + val showLoadingPlaceholder: Boolean, +) + +@Suppress("UNCHECKED_CAST") +fun deriveDemoUiState( + current: StoreResult, + previousData: StoreResult.Data?, +): DemoUiState { + val card = (current as? StoreResult.Data) ?: previousData + return DemoUiState( + card = card, + showSpinner = (current is StoreResult.Data<*> && current.refreshing) || + (current is StoreResult.Loading && card != null), + showStaleBadge = card?.isStale == true, + errorBanner = when { + current !is StoreResult.Error || card == null -> null + current.servedStale -> "Refresh failed — showing stale data" + else -> "Refresh failed" + }, + emptyError = if (current is StoreResult.Error && card == null) current.error else null, + showLoadingPlaceholder = current is StoreResult.Loading && card == null, + ) +} diff --git a/store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/Main.kt b/store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/Main.kt new file mode 100644 index 000000000..a87eba02d --- /dev/null +++ b/store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/Main.kt @@ -0,0 +1,19 @@ +@file:OptIn(ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.composedemo + +import androidx.compose.ui.window.singleWindowApplication +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.store + +fun main() { + val controls = DemoControls() + val users = store { fetcher(DemoFetcher(controls)) } + // Process-scoped store on the bounded-registry engine: idle key engines + // are LRU-bounded (default maxIdleKeys = 128 — this demo touches a single key, far under + // the bound) and evicted only after quiescence. No explicit close() here by choice: the + // window exit tears the JVM down, and core's close carries a GC-fallback posture. + singleWindowApplication(title = "store6-compose demo") { + DemoScreen(users, controls) + } +} diff --git a/store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/StabilityProbe.kt b/store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/StabilityProbe.kt new file mode 100644 index 000000000..c5838093e --- /dev/null +++ b/store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/StabilityProbe.kt @@ -0,0 +1,48 @@ +package org.mobilenativefoundation.store6.composedemo + +import androidx.compose.runtime.Composable +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreMeta +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.StoreResult + +/** + * Consumed by the CI compose-stability gate. Strict tier: concrete core classes — the shipped + * stability conf must make these stable (store6-core is not compiled with the compose compiler, + * so without the conf every core type is external-unstable). Iface tier: interface/abstract-typed + * parameters, which the gate currently requires to render stable as well. + */ +private fun consume(value: Any?) { + check(value !== StabilityProbeMarker) +} + +private object StabilityProbeMarker + +@Composable fun ProbeStrictData(value: StoreResult.Data) = consume(value) + +@Composable fun ProbeStrictLoading(value: StoreResult.Loading) = consume(value) + +@Composable fun ProbeStrictRevalidated(value: StoreResult.Revalidated) = consume(value) + +@Composable fun ProbeStrictError(value: StoreResult.Error) = consume(value) + +@Composable fun ProbeStrictMaxAge(value: Freshness.MaxAge) = consume(value) + +@Composable fun ProbeStrictOrigin(value: Origin) = consume(value) + +@Composable fun ProbeStrictNamespace(value: StoreNamespace) = consume(value) + +@Composable fun ProbeStrictFetchError(value: StoreError.Fetch) = consume(value) + +@Composable fun ProbeIfaceStoreResult(value: StoreResult) = consume(value) + +@Composable fun ProbeIfaceFreshness(value: Freshness) = consume(value) + +@Composable fun ProbeIfaceStoreKey(value: StoreKey) = consume(value) + +@Composable fun ProbeIfaceStoreMeta(value: StoreMeta) = consume(value) + +@Composable fun ProbeIfaceStoreError(value: StoreError) = consume(value) diff --git a/store6-compose-demo/src/test/kotlin/org/mobilenativefoundation/store6/composedemo/DemoFetcherTest.kt b/store6-compose-demo/src/test/kotlin/org/mobilenativefoundation/store6/composedemo/DemoFetcherTest.kt new file mode 100644 index 000000000..2d5bfb322 --- /dev/null +++ b/store6-compose-demo/src/test/kotlin/org/mobilenativefoundation/store6/composedemo/DemoFetcherTest.kt @@ -0,0 +1,36 @@ +@file:OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) + +package org.mobilenativefoundation.store6.composedemo + +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class DemoFetcherTest { + @Test + fun failureToggleProducesFetcherError() = runTest { + val controls = DemoControls().apply { failFetches.value = true } + val fetcher = DemoFetcher(controls) + assertIs(fetcher.fetch(UserKey("1"), etag = null)) + } + + @Test + fun unscriptedFetchesProduceVersionedUsersAndScriptedResultsWin() = runTest { + val controls = DemoControls().apply { latencyMillis.value = 3000L } + val fetcher = DemoFetcher(controls) + val key = UserKey("1") + val first = fetcher.fetch(key, etag = null) + assertIs>(first) + assertEquals("User 1 (v1)", first.value.name) + assertEquals(3000L * 1, currentTime) // virtual latency honored + fetcher.delegate.enqueue(key, FetcherResult.Success(User("1", "Scripted"))) + val second = fetcher.fetch(key, etag = null) + assertIs>(second) + assertEquals("Scripted", second.value.name) + } +} diff --git a/store6-compose-demo/src/test/kotlin/org/mobilenativefoundation/store6/composedemo/DemoUiStateTest.kt b/store6-compose-demo/src/test/kotlin/org/mobilenativefoundation/store6/composedemo/DemoUiStateTest.kt new file mode 100644 index 000000000..5afdae184 --- /dev/null +++ b/store6-compose-demo/src/test/kotlin/org/mobilenativefoundation/store6/composedemo/DemoUiStateTest.kt @@ -0,0 +1,78 @@ +@file:OptIn(ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.composedemo + +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.testing.TestStoreResults +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class DemoUiStateTest { + private fun data(name: String, stale: Boolean = false, refreshing: Boolean = false): StoreResult.Data = + TestStoreResults.data( + value = User("1", name), origin = Origin.FETCHER, isStale = stale, refreshing = refreshing, + ) + + @Test fun initialLoadingShowsPlaceholderOnly() { + val ui = deriveDemoUiState(TestStoreResults.loading(), previousData = null) + assertTrue(ui.showLoadingPlaceholder) + assertNull(ui.card) + assertFalse(ui.showSpinner) + assertNull(ui.errorBanner) + } + + @Test fun refreshingDataShowsSpinnerOverContent() { + val ui = deriveDemoUiState(data("alice", refreshing = true), previousData = null) + assertNotNull(ui.card) + assertTrue(ui.showSpinner) + assertFalse(ui.showLoadingPlaceholder) + } + + @Test fun loadingAfterDataRetainsCardAndSpins() { + val prev = data("alice") + val ui = deriveDemoUiState(TestStoreResults.loading(), previousData = prev) + assertSame(prev, ui.card) + assertTrue(ui.showSpinner) + assertFalse(ui.showLoadingPlaceholder) + } + + @Test fun staleCardShowsBadge() { + val ui = deriveDemoUiState(data("alice", stale = true), previousData = null) + assertTrue(ui.showStaleBadge) + } + + @Test fun servedStaleErrorBannersOverRetainedCard() { + val prev = data("alice", stale = true) + val error = TestStoreResults.error(TestStoreResults.fetchError("boom"), servedStale = true) + val ui = deriveDemoUiState(error, previousData = prev) + assertSame(prev, ui.card) + assertEquals("Refresh failed — showing stale data", ui.errorBanner) + assertTrue(ui.showStaleBadge) + assertNull(ui.emptyError) + } + + @Test fun freshErrorOverRetainedCardBannersWithoutTheStaleWording() { + val prev = data("alice") + val error = TestStoreResults.error(TestStoreResults.fetchError("boom"), servedStale = false) + val ui = deriveDemoUiState(error, previousData = prev) + assertSame(prev, ui.card) + assertEquals("Refresh failed", ui.errorBanner) + assertFalse(ui.showStaleBadge) + assertNull(ui.emptyError) + } + + @Test fun errorWithNoLocalValueSurfacesEmptyError() { + val error = TestStoreResults.error(TestStoreResults.fetchError("boom"), servedStale = false) + val ui = deriveDemoUiState(error, previousData = null) + assertNull(ui.card) + assertNull(ui.errorBanner) + assertNotNull(ui.emptyError) + } +} diff --git a/store6-compose/README.md b/store6-compose/README.md new file mode 100644 index 000000000..812c83b0f --- /dev/null +++ b/store6-compose/README.md @@ -0,0 +1,61 @@ +# store6-compose + +Compose Multiplatform integration for Store v6. Everything here is `@ExperimentalStoreApi`. +The seam it consumes is a freeze candidate, not frozen — see [STABILITY.md](../STABILITY.md). + +## Entry points + +- `Store.collectAsState(key, freshness)` — `State>`, starts at `Loading`, + restarts only on structural identity change (namespace/canonicalId/freshness), all targets. +- `Flow>.collectAsStoreState(initial)` — the flow-level variant. +- `Store.collectAsStateWithLifecycle(...)` / `collectAsStoreStateWithLifecycle(...)` — + lifecycle-gated via `repeatOnLifecycle`, on all targets. These need a `LifecycleOwner`; on + targets with no UI host that populates `LocalLifecycleOwner`, pass one explicitly. +- `skipEqualData()` / `storeResultMutationPolicy()` — structural skipping for stateIn/ViewModel + flows and custom state holders. + +## Recomposition discipline + +`StoreResult` types deliberately have identity equality. This module skips recomposition by +structural comparison of `Data`'s value/origin/isStale/refreshing — `age` is excluded (it +advances every emission). Results are never merged across kinds. That mirrors the engine's +`conflateLatestData` rule (same-kind latest-wins; never merged across +kinds): "Revalidated is a lifecycle signal: `conflateLatestData` never conflates it away in +favor of another kind; for a blocked collector a newer `Revalidated` supersedes an older queued +one, so the kind itself is never lost." This module is stricter still — `Loading`/`Revalidated`/ +`Error` always pass; only structurally-equal consecutive `Data` frames are dropped. Event-shaped +consumption of `Revalidated`/`Error` should collect the Flow, not a State. + +## Stability configuration for consumers + +Strong skipping (default since Kotlin 2.0.20) compares unstable parameters by instance; this +module's state holders keep instances stable across equal frames, so skipping works out of the +box. To make store types compare as stable values instead — which is what lets the compiler skip +on equal *content* rather than equal *instance* — add the shipped snippet +(`stability/store6-stability.conf`, reproduced below) to your app module: + + composeCompiler { + stabilityConfigurationFiles.add( + layout.projectDirectory.file("store6-stability.conf"), + ) + } + + // store6-stability.conf (mirror of the shipped file) + org.mobilenativefoundation.store6.core.* + org.mobilenativefoundation.store6.core.seam.* + +CI verifies this exact snippet against a tiered probe of core public types on every PR. With the +snippet applied, every probed core type — including the interface-typed ones (`StoreResult`, +`Freshness`, `StoreKey`, `StoreMeta`, `StoreError`) and the generic `StoreResult.Data` — +resolves as **stable**; without it, they resolve as `unstable` and the CI gate fails. + +Note that `composeCompiler.stabilityConfigurationFiles` is not registered as a Gradle task input +by the Compose compiler plugin, and the emitted stability reports are an undeclared output. This +module's build scripts compensate (`inputs.file(...)` plus opting the demo compilations out of +the build cache) so that editing the conf always re-emits a matching report; consumers relying on +their own report-based checks should do the same. + +## Demo + +`./gradlew :store6-compose-demo:run` — refreshing spinner-over-content, STALE badge, and +error-with-stale-data against a fake fetcher with toggleable latency and failure. diff --git a/store6-compose/api/android/store6-compose.api b/store6-compose/api/android/store6-compose.api new file mode 100644 index 000000000..304aa0b10 --- /dev/null +++ b/store6-compose/api/android/store6-compose.api @@ -0,0 +1,17 @@ +public final class org/mobilenativefoundation/store6/compose/CollectAsStateKt { + public static final fun collectAsState (Lorg/mobilenativefoundation/store6/core/Store;Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Freshness;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/State; + public static final fun collectAsStoreState (Lkotlinx/coroutines/flow/Flow;Lorg/mobilenativefoundation/store6/core/StoreResult;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/State; +} + +public final class org/mobilenativefoundation/store6/compose/CollectAsStateWithLifecycleKt { + public static final fun collectAsStateWithLifecycle (Lorg/mobilenativefoundation/store6/core/Store;Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Freshness;Landroidx/lifecycle/LifecycleOwner;Landroidx/lifecycle/Lifecycle$State;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/State; + public static final fun collectAsStoreStateWithLifecycle (Lkotlinx/coroutines/flow/Flow;Lorg/mobilenativefoundation/store6/core/StoreResult;Landroidx/lifecycle/LifecycleOwner;Landroidx/lifecycle/Lifecycle$State;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/State; +} + +public final class org/mobilenativefoundation/store6/compose/StoreResultEquivalenceKt { + public static final fun skipEqualData (Lkotlinx/coroutines/flow/Flow;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/Flow; + public static synthetic fun skipEqualData$default (Lkotlinx/coroutines/flow/Flow;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; + public static final fun storeResultMutationPolicy (Lkotlin/jvm/functions/Function2;)Landroidx/compose/runtime/SnapshotMutationPolicy; + public static synthetic fun storeResultMutationPolicy$default (Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Landroidx/compose/runtime/SnapshotMutationPolicy; +} + diff --git a/store6-compose/api/jvm/store6-compose.api b/store6-compose/api/jvm/store6-compose.api new file mode 100644 index 000000000..304aa0b10 --- /dev/null +++ b/store6-compose/api/jvm/store6-compose.api @@ -0,0 +1,17 @@ +public final class org/mobilenativefoundation/store6/compose/CollectAsStateKt { + public static final fun collectAsState (Lorg/mobilenativefoundation/store6/core/Store;Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Freshness;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/State; + public static final fun collectAsStoreState (Lkotlinx/coroutines/flow/Flow;Lorg/mobilenativefoundation/store6/core/StoreResult;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/State; +} + +public final class org/mobilenativefoundation/store6/compose/CollectAsStateWithLifecycleKt { + public static final fun collectAsStateWithLifecycle (Lorg/mobilenativefoundation/store6/core/Store;Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Freshness;Landroidx/lifecycle/LifecycleOwner;Landroidx/lifecycle/Lifecycle$State;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/State; + public static final fun collectAsStoreStateWithLifecycle (Lkotlinx/coroutines/flow/Flow;Lorg/mobilenativefoundation/store6/core/StoreResult;Landroidx/lifecycle/LifecycleOwner;Landroidx/lifecycle/Lifecycle$State;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/State; +} + +public final class org/mobilenativefoundation/store6/compose/StoreResultEquivalenceKt { + public static final fun skipEqualData (Lkotlinx/coroutines/flow/Flow;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/Flow; + public static synthetic fun skipEqualData$default (Lkotlinx/coroutines/flow/Flow;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; + public static final fun storeResultMutationPolicy (Lkotlin/jvm/functions/Function2;)Landroidx/compose/runtime/SnapshotMutationPolicy; + public static synthetic fun storeResultMutationPolicy$default (Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Landroidx/compose/runtime/SnapshotMutationPolicy; +} + diff --git a/store6-compose/api/store6-compose.klib.api b/store6-compose/api/store6-compose.klib.api new file mode 100644 index 000000000..83587363a --- /dev/null +++ b/store6-compose/api/store6-compose.klib.api @@ -0,0 +1,14 @@ +// Klib ABI Dump +// Targets: [iosArm64, iosSimulatorArm64, iosX64, js, linuxX64, macosArm64, mingwX64, tvosArm64, wasmJs, watchosArm64] +// Rendering settings: +// - Signature version: 2 +// - Show manifest properties: true +// - Show declarations: true + +// Library unique name: +final fun <#A: kotlin/Any?> (kotlinx.coroutines.flow/Flow>).org.mobilenativefoundation.store6.compose/collectAsStoreState(org.mobilenativefoundation.store6.core/StoreResult<#A>?, kotlin/Function2<#A, #A, kotlin/Boolean>?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): androidx.compose.runtime/State> // org.mobilenativefoundation.store6.compose/collectAsStoreState|collectAsStoreState@kotlinx.coroutines.flow.Flow>(org.mobilenativefoundation.store6.core.StoreResult<0:0>?;kotlin.Function2<0:0,0:0,kotlin.Boolean>?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){0§}[0] +final fun <#A: kotlin/Any?> (kotlinx.coroutines.flow/Flow>).org.mobilenativefoundation.store6.compose/collectAsStoreStateWithLifecycle(org.mobilenativefoundation.store6.core/StoreResult<#A>?, androidx.lifecycle/LifecycleOwner?, androidx.lifecycle/Lifecycle.State?, kotlin/Function2<#A, #A, kotlin/Boolean>?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): androidx.compose.runtime/State> // org.mobilenativefoundation.store6.compose/collectAsStoreStateWithLifecycle|collectAsStoreStateWithLifecycle@kotlinx.coroutines.flow.Flow>(org.mobilenativefoundation.store6.core.StoreResult<0:0>?;androidx.lifecycle.LifecycleOwner?;androidx.lifecycle.Lifecycle.State?;kotlin.Function2<0:0,0:0,kotlin.Boolean>?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){0§}[0] +final fun <#A: kotlin/Any?> (kotlinx.coroutines.flow/Flow>).org.mobilenativefoundation.store6.compose/skipEqualData(kotlin/Function2<#A, #A, kotlin/Boolean> = ...): kotlinx.coroutines.flow/Flow> // org.mobilenativefoundation.store6.compose/skipEqualData|skipEqualData@kotlinx.coroutines.flow.Flow>(kotlin.Function2<0:0,0:0,kotlin.Boolean>){0§}[0] +final fun <#A: kotlin/Any?> org.mobilenativefoundation.store6.compose/storeResultMutationPolicy(kotlin/Function2<#A, #A, kotlin/Boolean> = ...): androidx.compose.runtime/SnapshotMutationPolicy> // org.mobilenativefoundation.store6.compose/storeResultMutationPolicy|storeResultMutationPolicy(kotlin.Function2<0:0,0:0,kotlin.Boolean>){0§}[0] +final fun <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> (org.mobilenativefoundation.store6.core/Store<#A, #B>).org.mobilenativefoundation.store6.compose/collectAsState(#A, org.mobilenativefoundation.store6.core/Freshness?, kotlin/Function2<#B, #B, kotlin/Boolean>?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): androidx.compose.runtime/State> // org.mobilenativefoundation.store6.compose/collectAsState|collectAsState@org.mobilenativefoundation.store6.core.Store<0:0,0:1>(0:0;org.mobilenativefoundation.store6.core.Freshness?;kotlin.Function2<0:1,0:1,kotlin.Boolean>?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){0§;1§}[0] +final fun <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> (org.mobilenativefoundation.store6.core/Store<#A, #B>).org.mobilenativefoundation.store6.compose/collectAsStateWithLifecycle(#A, org.mobilenativefoundation.store6.core/Freshness?, androidx.lifecycle/LifecycleOwner?, androidx.lifecycle/Lifecycle.State?, kotlin/Function2<#B, #B, kotlin/Boolean>?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): androidx.compose.runtime/State> // org.mobilenativefoundation.store6.compose/collectAsStateWithLifecycle|collectAsStateWithLifecycle@org.mobilenativefoundation.store6.core.Store<0:0,0:1>(0:0;org.mobilenativefoundation.store6.core.Freshness?;androidx.lifecycle.LifecycleOwner?;androidx.lifecycle.Lifecycle.State?;kotlin.Function2<0:1,0:1,kotlin.Boolean>?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){0§;1§}[0] diff --git a/store6-compose/build.gradle.kts b/store6-compose/build.gradle.kts new file mode 100644 index 000000000..7c8359c6c --- /dev/null +++ b/store6-compose/build.gradle.kts @@ -0,0 +1,54 @@ +plugins { + id("org.mobilenativefoundation.store.store6.multiplatform") + alias(libs.plugins.kotlin.compose.compiler) +} + +val store6StabilityConfig = layout.projectDirectory.file("stability/store6-stability.conf") + +composeCompiler { + // Dogfoods the shipped consumer snippet: core types are stable inside this module's own + // composables. The conf file lands in this same task (T1) so this wiring never dangles. + stabilityConfigurationFiles.add(store6StabilityConfig) +} + +// The Compose compiler plugin does not register stabilityConfigurationFiles as a task input, so +// a conf-only edit would otherwise leave these compilations UP-TO-DATE against stale settings. +tasks.withType>().configureEach { + inputs.file(store6StabilityConfig).withPathSensitivity(PathSensitivity.RELATIVE) +} + +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + api(projects.store6Core) + // PIN (preflight): CMP 1.8.2 is the pinned Compose Multiplatform runtime line. + api(libs.jetbrains.compose.runtime) + // lifecycle-runtime-compose 2.9.1 publishes every canonical Store6 target, + // including linuxX64, mingwX64, tvosArm64 and watchosArm64 (verified against the + // published Gradle module metadata), so the lifecycle-gated entry points live in + // commonMain and ship on all 12 targets rather than a restricted tier. + api(libs.jetbrains.lifecycle.runtime.compose) + } + } + val commonTest by getting { + dependencies { + implementation(projects.store6Testing) + implementation(libs.kotlinx.coroutines.test) + } + } + } +} + +android { + namespace = "org.mobilenativefoundation.store6.compose" + + // The shared commonTest suites drive a real Composition on every target, and the Compose + // runtime traces composition disposal through android.os.Trace. Under Android local unit + // tests that class is an unimplemented android.jar stub, so it throws instead of no-opping. + // Returning stub defaults keeps the Android variant running the same suites as every other + // target; no assertion in this module depends on an android.jar return value. + testOptions { + unitTests.isReturnDefaultValues = true + } +} diff --git a/store6-compose/gradle.properties b/store6-compose/gradle.properties new file mode 100644 index 000000000..79da1c9a2 --- /dev/null +++ b/store6-compose/gradle.properties @@ -0,0 +1,3 @@ +VERSION_NAME=6.0.0-SNAPSHOT +POM_NAME=store6-compose +POM_ARTIFACT_ID=store6-compose diff --git a/rx2/src/main/AndroidManifest.xml b/store6-compose/src/androidMain/AndroidManifest.xml similarity index 100% rename from rx2/src/main/AndroidManifest.xml rename to store6-compose/src/androidMain/AndroidManifest.xml diff --git a/store6-compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/CollectAsState.kt b/store6-compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/CollectAsState.kt new file mode 100644 index 000000000..de42634ae --- /dev/null +++ b/store6-compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/CollectAsState.kt @@ -0,0 +1,79 @@ +package org.mobilenativefoundation.store6.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import kotlinx.coroutines.flow.Flow +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.seam.StoreResults + +/** + * Collects [Store.stream] for [key] as compose [State], starting at [StoreResult.Loading]. + * Collection restarts only when the store instance or the structural stream identity changes: + * `(namespace.value, canonicalId(), freshness)` with [Freshness.MaxAge] compared by its duration — + * a new-but-equal key or policy instance never restarts collection. Structurally equal consecutive + * [StoreResult.Data] frames do not invalidate readers (see [storeResultMutationPolicy]; age + * excluded). [valueEquivalence] is captured at first composition for a given restart identity. + * Collection is scoped to the composition; for lifecycle-gated collection use + * `collectAsStateWithLifecycle` on the CMP lifecycle tier. + * + * Closed-store behavior: calling this on a closed store fails the composition — [Store.stream] + * throws [IllegalStateException] inside the launched effect (the stream is guarded both at call + * and at collection start); a collection cancelled by [Store.close] ends as coroutine + * cancellation. The close message is engine-internal diagnostic text, not ABI. + */ +@ExperimentalStoreApi +@Composable +public fun Store.collectAsState( + key: K, + freshness: Freshness = Freshness.CachedOrFetch, + valueEquivalence: (V, V) -> Boolean = { a, b -> a == b }, +): State> { + val restartKey = streamRestartKey(key, freshness) + val state = remember(this, restartKey) { + mutableStateOf>(StoreResults.loading(), storeResultMutationPolicy(valueEquivalence)) + } + LaunchedEffect(this, restartKey) { + stream(key, freshness).collect { state.value = it } + } + return state +} + +/** + * Collects a flow of store results as compose [State] beginning at [initial] (default + * [StoreResults.loading]), holding it with [storeResultMutationPolicy] so structurally equal + * consecutive [StoreResult.Data] frames skip recomposition while lifecycle results always land. + * Collection restarts when the flow instance changes and is scoped to the composition. + */ +@ExperimentalStoreApi +@Composable +public fun Flow>.collectAsStoreState( + initial: StoreResult = StoreResults.loading(), + valueEquivalence: (V, V) -> Boolean = { a, b -> a == b }, +): State> { + val state = remember(this) { + mutableStateOf(initial, storeResultMutationPolicy(valueEquivalence)) + } + LaunchedEffect(this) { collect { state.value = it } } + return state +} + +internal fun streamRestartKey(key: StoreKey, freshness: Freshness): Any = + Triple(key.namespace.value, key.canonicalId(), freshnessToken(freshness)) + +private fun freshnessToken(freshness: Freshness): Any = + when (freshness) { + is Freshness.MaxAge -> "MaxAge:${freshness.notOlderThan}" + // GUARD: every other `Freshness` variant is a `data object`, so instance identity IS a + // stable token. MaxAge is the sole plain class with identity equality and must be + // normalized by value. If core ever adds another non-singleton Freshness, add a branch + // for it here — otherwise a new-but-equal instance silently restarts collection on every + // recomposition, which is exactly the footgun the MaxAge branch exists to prevent. + else -> freshness + } diff --git a/store6-compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/CollectAsStateWithLifecycle.kt b/store6-compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/CollectAsStateWithLifecycle.kt new file mode 100644 index 000000000..2e78f767d --- /dev/null +++ b/store6-compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/CollectAsStateWithLifecycle.kt @@ -0,0 +1,71 @@ +package org.mobilenativefoundation.store6.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.flow.Flow +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.seam.StoreResults + +/** + * Lifecycle-gated [collectAsState]: collection runs only at or above [minActiveState] via + * [repeatOnLifecycle], retains the last result while stopped, and re-collects [Store.stream] + * from scratch on re-entry (the engine re-emits the current snapshot first, so the State + * catches up without a Loading reset). Under the bounded key registry a paused collection + * releases its engine refcount; a quiescent idle engine may be evicted (LRU, default + * `maxIdleKeys` 128) and is transparently rebuilt on re-entry — no API-visible difference. + * Requires a populated [LocalLifecycleOwner] (any CMP UI host or Android component provides + * one) unless [lifecycleOwner] is passed explicitly. On targets with no UI host that populates + * it — linuxX64, mingwX64 and the non-simulator Apple targets, in practice — pass + * [lifecycleOwner] explicitly or use [collectAsState]. + */ +@ExperimentalStoreApi +@Composable +public fun Store.collectAsStateWithLifecycle( + key: K, + freshness: Freshness = Freshness.CachedOrFetch, + lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, + minActiveState: Lifecycle.State = Lifecycle.State.STARTED, + valueEquivalence: (V, V) -> Boolean = { a, b -> a == b }, +): State> { + val restartKey = streamRestartKey(key, freshness) + val state = remember(this, restartKey) { + mutableStateOf>(StoreResults.loading(), storeResultMutationPolicy(valueEquivalence)) + } + LaunchedEffect(this, restartKey, lifecycleOwner, minActiveState) { + lifecycleOwner.repeatOnLifecycle(minActiveState) { + stream(key, freshness).collect { state.value = it } + } + } + return state +} + +/** Lifecycle-gated [collectAsStoreState]; see [collectAsStateWithLifecycle]. */ +@ExperimentalStoreApi +@Composable +public fun Flow>.collectAsStoreStateWithLifecycle( + initial: StoreResult = StoreResults.loading(), + lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, + minActiveState: Lifecycle.State = Lifecycle.State.STARTED, + valueEquivalence: (V, V) -> Boolean = { a, b -> a == b }, +): State> { + val state = remember(this) { + mutableStateOf(initial, storeResultMutationPolicy(valueEquivalence)) + } + LaunchedEffect(this, lifecycleOwner, minActiveState) { + lifecycleOwner.repeatOnLifecycle(minActiveState) { + collect { state.value = it } + } + } + return state +} diff --git a/store6-compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/StoreResultEquivalence.kt b/store6-compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/StoreResultEquivalence.kt new file mode 100644 index 000000000..ab3fcf59c --- /dev/null +++ b/store6-compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/StoreResultEquivalence.kt @@ -0,0 +1,65 @@ +package org.mobilenativefoundation.store6.compose + +import androidx.compose.runtime.SnapshotMutationPolicy +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreResult + +/** + * Structural equivalence for snapshot state holding [StoreResult]: two [StoreResult.Data] are + * equivalent iff origin, isStale, and refreshing match and [valueEquivalence] accepts the values. + * [StoreResult.Data.age] is deliberately excluded: it advances on every emission and would defeat + * recomposition skipping; derive live age from a clock when displaying it. Results of different + * kinds are never equivalent, and lifecycle results (Loading, Revalidated, Error) are never + * merged except as identical instances — a State is a conflated container, so event-shaped + * consumption of Revalidated/Error must collect the Flow. This exists because StoreResult types + * have identity equality by design (no equals override). + */ +@ExperimentalStoreApi +public fun storeResultMutationPolicy( + valueEquivalence: (V, V) -> Boolean = { a, b -> a == b }, +): SnapshotMutationPolicy> = object : SnapshotMutationPolicy> { + override fun equivalent(a: StoreResult, b: StoreResult): Boolean = + structurallyEquivalent(a, b, valueEquivalence) +} + +/** + * Drops structurally-equal consecutive [StoreResult.Data] frames; every lifecycle result + * (Loading, Revalidated, Error) always passes, and no result is ever dropped in favor of a + * different kind. This mirrors the engine's `conflateLatestData` discipline — same-kind + * latest-wins, never merged across kinds — whose public contract reads: "Revalidated is a + * lifecycle signal: `conflateLatestData` never conflates it away in favor of another kind; for a + * blocked collector a newer `Revalidated` supersedes an older queued one, so the kind itself is + * never lost." This operator is stricter still: it never supersedes lifecycle results at all — + * only exact structural Data duplicates are dropped. Age is excluded from the comparison (see + * [storeResultMutationPolicy]). This is a store6-compose convenience for stateIn/ViewModel + * consumers; `conflateLatestData` governs store6-core's own stream, not this module. + */ +@ExperimentalStoreApi +public fun Flow>.skipEqualData( + valueEquivalence: (V, V) -> Boolean = { a, b -> a == b }, +): Flow> = flow { + var previous: StoreResult? = null + collect { result -> + val last = previous + previous = result + val duplicate = last is StoreResult.Data<*> && result is StoreResult.Data<*> && + structurallyEquivalent(last, result, valueEquivalence) + if (!duplicate) emit(result) + } +} + +@Suppress("UNCHECKED_CAST") +internal fun structurallyEquivalent( + a: StoreResult, + b: StoreResult, + valueEquivalence: (V, V) -> Boolean, +): Boolean { + if (a === b) return true + if (a !is StoreResult.Data<*> || b !is StoreResult.Data<*>) return false + return a.origin == b.origin && + a.isStale == b.isStale && + a.refreshing == b.refreshing && + valueEquivalence(a.value as V, b.value as V) +} diff --git a/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/ClosedStoreBehaviorTest.kt b/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/ClosedStoreBehaviorTest.kt new file mode 100644 index 000000000..4d318a007 --- /dev/null +++ b/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/ClosedStoreBehaviorTest.kt @@ -0,0 +1,68 @@ +@file:OptIn(ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.compose + +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.testing.FakeStore +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.test.runTest as coroutineRunTest + +/** + * Type-only characterization of the closed-store behavior the composables inherit, asserted at the + * exact `Store.stream` seam they call. The close message is engine-internal diagnostic text, not + * ABI, so no message text is asserted here. + */ +class ClosedStoreBehaviorTest { + private class TestKey(val id: String) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("users") + + override fun canonicalId(): String = id + } + + @Test + fun streamOnClosedStoreThrowsIllegalStateException() { + val store = FakeStore() + store.close() + assertFailsWith { store.stream(TestKey("1")) } + } + + /** + * The stream is guarded twice — at call and again at collection start. This is the second + * guard: the Flow is obtained while the store is open, so only the collection-start check can + * reject it. That is the path a composable takes when the store closes between composition + * and the LaunchedEffect body running. + */ + @Test + fun streamObtainedBeforeCloseThrowsAtCollectionStart(): TestResult = runTest { + val store = FakeStore() + val key = TestKey("1") + store.setValue(key, "v1") + val stream = store.stream(key) + store.close() + assertFailsWith { stream.collect {} } + } + + @Test + fun closeDuringCollectionEndsAsCancellation(): TestResult = runTest { + val store = FakeStore() + val key = TestKey("1") + store.setValue(key, "v1") + val collector = launch { store.stream(key).collect {} } + testScheduler.runCurrent() // collection is live + store.close() + collector.join() + assertTrue(collector.isCancelled) + } +} + +// One file-private 25s runTest shadow, no nested wall-clock waits. +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = 25.seconds, testBody = testBody) diff --git a/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/CollectAsStateTest.kt b/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/CollectAsStateTest.kt new file mode 100644 index 000000000..923736a29 --- /dev/null +++ b/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/CollectAsStateTest.kt @@ -0,0 +1,167 @@ +@file:OptIn(ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.compose + +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.TestResult +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.testing.FakeStore +import org.mobilenativefoundation.store6.testing.TestStoreResults +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertSame +import kotlin.time.Duration.Companion.milliseconds + +class CollectAsStateTest { + private class TestKey(val id: String) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("users") + + override fun canonicalId(): String = id + } + + @Test + fun firstCompositionReportsLoadingThenScriptedData(): TestResult { + val store = FakeStore() + val key = TestKey("1") + store.enqueueFetchValue(key, "alice") + val observed = mutableListOf>() + lateinit var state: State> + return runComposeTest(content = { + state = store.collectAsState(key) + observed += state.value + }) { host -> + assertIs(observed.first()) + host.awaitUntil { state.value is StoreResult.Data<*> } + assertEquals("alice", (state.value as StoreResult.Data).value) + } + } + + @Test + fun structurallyEqualDataFramesDoNotRecompose(): TestResult { + val flow = MutableSharedFlow>(replay = 1) + var recompositions = 0 + // What the composition actually READ, as opposed to what the State already holds: a + // State write lands a frame before the recomposition that observes it, so counting + // recompositions against the raw State would sample a stale baseline. + var rendered: StoreResult? = null + fun data(value: String, ageMillis: Long) = TestStoreResults.data( + value = value, origin = Origin.FETCHER, age = ageMillis.milliseconds, + isStale = false, refreshing = false, + ) + return runComposeTest(content = { + val state = flow.collectAsStoreState() + recompositions += 1 + rendered = state.value + }) { host -> + flow.emit(data("alice", 0)) + host.awaitUntil { (rendered as? StoreResult.Data)?.value == "alice" } + host.advanceFrame() // drain any frame still in flight before sampling + val baseline = recompositions + flow.emit(data("alice", 40)) // new instance, equal sans age + host.advanceFrame() + host.advanceFrame() + assertEquals(baseline, recompositions) + flow.emit(data("bob", 80)) + host.awaitUntil { (rendered as? StoreResult.Data)?.value == "bob" } + assertEquals(baseline + 1, recompositions) + } + } + + @Test + fun lifecycleFramesAlwaysRecompose(): TestResult { + val flow = MutableSharedFlow>(replay = 1) + var recompositions = 0 + var rendered: StoreResult? = null + return runComposeTest(content = { + val state = flow.collectAsStoreState() + recompositions += 1 + rendered = state.value + }) { host -> + flow.emit(TestStoreResults.error(TestStoreResults.fetchError("boom"), servedStale = true)) + host.awaitUntil { rendered is StoreResult.Error } + host.advanceFrame() // drain any frame still in flight before sampling + val afterFirst = recompositions + val firstRendered = rendered + // A distinct but structurally identical Error instance must still land: lifecycle + // results are never merged, only Data duplicates are dropped. + flow.emit(TestStoreResults.error(TestStoreResults.fetchError("boom"), servedStale = true)) + host.awaitUntil { rendered !== firstRendered } + assertEquals(afterFirst + 1, recompositions) + } + } + + @Test + fun equalKeyInstanceDoesNotRestartAndNewKeyDoes(): TestResult { + val store = FakeStore() + store.setValue(TestKey("1"), "alice") + store.setValue(TestKey("2"), "bob") + val currentKey = mutableStateOf(TestKey("1")) + lateinit var state: State> + return runComposeTest(content = { + val key = currentKey.value + state = store.collectAsState(key) + }) { host -> + host.awaitUntil { (state.value as? StoreResult.Data)?.value == "alice" } + val interactionsAfterFirst = store.interactions.size + currentKey.value = TestKey("1") // new instance, same identity + host.advanceFrame() + host.advanceFrame() + assertEquals(interactionsAfterFirst, store.interactions.size) + assertEquals("alice", (state.value as StoreResult.Data).value) + currentKey.value = TestKey("2") + host.awaitUntil { (state.value as? StoreResult.Data)?.value == "bob" } + } + } + + /** + * [Freshness.MaxAge] is the one Freshness subtype that is a plain class with identity + * equality, so the restart key normalizes it by duration. Without that normalization the + * natural call shape — allocating `MaxAge(...)` inline in the composable — would restart + * collection on every recomposition. + */ + @Test + fun equalMaxAgeInstanceDoesNotRestartAndDifferentMaxAgeDoes(): TestResult { + val store = FakeStore() + val key = TestKey("1") + store.setValue(key, "alice") + val freshness = mutableStateOf(Freshness.MaxAge(30.milliseconds)) + lateinit var state: State> + return runComposeTest(content = { + state = store.collectAsState(key, freshness.value) + }) { host -> + host.awaitUntil { (state.value as? StoreResult.Data)?.value == "alice" } + val afterFirst = store.interactions.size + freshness.value = Freshness.MaxAge(30.milliseconds) // new instance, equal duration + host.advanceFrame() + host.advanceFrame() + assertEquals(afterFirst, store.interactions.size) + freshness.value = Freshness.MaxAge(90.milliseconds) // different duration + host.awaitUntil { store.interactions.size > afterFirst } + assertEquals(afterFirst + 1, store.interactions.size) + } + } + + @Test + fun initialResultIsRenderedBeforeAnyEmission(): TestResult { + val flow = MutableSharedFlow>(replay = 1) + val seeded = TestStoreResults.data( + value = "seed", origin = Origin.MEMORY, isStale = true, refreshing = false, + ) + var rendered: StoreResult? = null + return runComposeTest(content = { + rendered = flow.collectAsStoreState(initial = seeded).value + }) { host -> + assertSame(seeded, rendered) + flow.emit(TestStoreResults.data(value = "fresh", origin = Origin.FETCHER)) + host.awaitUntil { (rendered as? StoreResult.Data)?.value == "fresh" } + } + } +} diff --git a/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/CollectAsStateWithLifecycleTest.kt b/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/CollectAsStateWithLifecycleTest.kt new file mode 100644 index 000000000..af87b67bc --- /dev/null +++ b/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/CollectAsStateWithLifecycleTest.kt @@ -0,0 +1,123 @@ +@file:OptIn(ExperimentalStoreApi::class, ExperimentalCoroutinesApi::class) + +package org.mobilenativefoundation.store6.compose + +import androidx.compose.runtime.State +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.testing.FakeStore +import org.mobilenativefoundation.store6.testing.TestStoreResults +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class CollectAsStateWithLifecycleTest { + private class TestKey(val id: String) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("users") + + override fun canonicalId(): String = id + } + + private class TestOwner : LifecycleOwner { + private val registry = LifecycleRegistry.createUnsafe(this) + + override val lifecycle: Lifecycle get() = registry + + fun moveTo(state: Lifecycle.State) { + registry.currentState = state + } + } + + @BeforeTest fun setUp() = Dispatchers.setMain(StandardTestDispatcher()) + + @AfterTest fun tearDown() = Dispatchers.resetMain() + + @Test + fun collectionPausesBelowMinActiveStateAndCatchesUpOnReentry(): TestResult { + val store = FakeStore() + val key = TestKey("1") + store.setValue(key, "v1") + val owner = TestOwner().apply { moveTo(Lifecycle.State.STARTED) } + lateinit var state: State> + return runComposeTest(content = { + state = store.collectAsStateWithLifecycle(key, lifecycleOwner = owner) + }) { host -> + host.awaitUntil { (state.value as? StoreResult.Data)?.value == "v1" } + owner.moveTo(Lifecycle.State.CREATED) + host.advanceFrame() + store.setValue(key, "v2") + host.advanceFrame() + host.advanceFrame() + assertEquals("v1", (state.value as StoreResult.Data).value) // retained, not reset + owner.moveTo(Lifecycle.State.STARTED) + host.awaitUntil { (state.value as? StoreResult.Data)?.value == "v2" } + } + } + + /** + * The documented contract is that re-entry retains the last result rather than resetting to + * Loading. A `Store.stream` re-emits its current snapshot immediately, which would paper over + * a reset, so this asserts against a replayless flow: on re-entry nothing arrives, and the + * only way the state can be anything other than the retained value is an actual reset. + */ + @Test + fun reentryRetainsTheLastResultInsteadOfResettingToLoading(): TestResult { + val flow = MutableSharedFlow>() // replay = 0: re-entry re-delivers nothing + val owner = TestOwner().apply { moveTo(Lifecycle.State.STARTED) } + lateinit var state: State> + return runComposeTest(content = { + state = flow.collectAsStoreStateWithLifecycle(lifecycleOwner = owner) + }) { host -> + flow.emit(TestStoreResults.data(value = "a", origin = Origin.FETCHER)) + host.awaitUntil { (state.value as? StoreResult.Data)?.value == "a" } + owner.moveTo(Lifecycle.State.CREATED) + host.advanceFrame() + owner.moveTo(Lifecycle.State.STARTED) + repeat(5) { host.advanceFrame() } + assertTrue( + state.value is StoreResult.Data<*>, + "re-entry discarded the retained result: ${state.value}", + ) + assertEquals("a", (state.value as StoreResult.Data).value) + } + } + + @Test + fun flowVariantPausesBelowMinActiveStateAndResumes(): TestResult { + val flow = MutableSharedFlow>(replay = 1) + val owner = TestOwner().apply { moveTo(Lifecycle.State.STARTED) } + val seeded = TestStoreResults.data(value = "seed", origin = Origin.MEMORY) + lateinit var state: State> + return runComposeTest(content = { + state = flow.collectAsStoreStateWithLifecycle(initial = seeded, lifecycleOwner = owner) + }) { host -> + assertSame(seeded, state.value) + flow.emit(TestStoreResults.data(value = "a", origin = Origin.FETCHER)) + host.awaitUntil { (state.value as? StoreResult.Data)?.value == "a" } + owner.moveTo(Lifecycle.State.CREATED) + host.advanceFrame() + flow.emit(TestStoreResults.data(value = "b", origin = Origin.FETCHER)) + host.advanceFrame() + host.advanceFrame() + assertEquals("a", (state.value as StoreResult.Data).value) // paused, not collected + owner.moveTo(Lifecycle.State.STARTED) + host.awaitUntil { (state.value as? StoreResult.Data)?.value == "b" } + } + } +} diff --git a/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/ComposeTestHarness.kt b/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/ComposeTestHarness.kt new file mode 100644 index 000000000..cff723e70 --- /dev/null +++ b/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/ComposeTestHarness.kt @@ -0,0 +1,68 @@ +package org.mobilenativefoundation.store6.compose + +import androidx.compose.runtime.AbstractApplier +import androidx.compose.runtime.BroadcastFrameClock +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Composition +import androidx.compose.runtime.Recomposer +import androidx.compose.runtime.snapshots.Snapshot +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.test.runTest as coroutineRunTest + +private object UnitApplier : AbstractApplier(Unit) { + override fun insertBottomUp(index: Int, instance: Unit) {} + + override fun insertTopDown(index: Int, instance: Unit) {} + + override fun move(from: Int, to: Int, count: Int) {} + + override fun remove(index: Int, count: Int) {} + + override fun onClear() {} +} + +/** + * Drives a composition entirely from the test scheduler: no wall clock, no `Dispatchers.Default` + * hop, no nested `withTimeout`. Every frame is pumped explicitly through [advanceFrame]. + */ +internal class ComposeHost(private val scope: TestScope, private val clock: BroadcastFrameClock) { + fun advanceFrame() { + Snapshot.sendApplyNotifications() + scope.testScheduler.runCurrent() + clock.sendFrame(0L) + scope.testScheduler.runCurrent() + } + + fun awaitUntil(limit: Int = 50, predicate: () -> Boolean) { + repeat(limit) { if (predicate()) return else advanceFrame() } + check(predicate()) { "condition not reached within $limit frames" } + } +} + +internal fun runComposeTest( + content: @Composable () -> Unit, + block: suspend TestScope.(ComposeHost) -> Unit, +): TestResult = runTest { + val clock = BroadcastFrameClock() + val recomposer = Recomposer(coroutineContext + clock) + val runner = launch(clock) { recomposer.runRecomposeAndApplyChanges() } + val composition = Composition(UnitApplier, recomposer) + val host = ComposeHost(this, clock) + try { + composition.setContent(content) + host.advanceFrame() + block(host) + } finally { + composition.dispose() + recomposer.close() + runner.cancelAndJoin() + } +} + +// One file-private 25s runTest shadow, no nested wall-clock waits. +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = 25.seconds, testBody = testBody) diff --git a/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/SkipEqualDataTest.kt b/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/SkipEqualDataTest.kt new file mode 100644 index 000000000..da05a15e5 --- /dev/null +++ b/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/SkipEqualDataTest.kt @@ -0,0 +1,74 @@ +@file:OptIn(ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.compose + +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.testing.TestStoreResults +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +class SkipEqualDataTest { + private fun data( + value: String, + stale: Boolean = false, + refreshing: Boolean = false, + age: Duration = Duration.ZERO, + origin: Origin = Origin.FETCHER, + ): StoreResult = TestStoreResults.data( + value = value, origin = origin, age = age, isStale = stale, refreshing = refreshing, + ) + + @Test + fun dropsStructurallyEqualConsecutiveData(): TestResult = runTest { + val out = flowOf(data("a"), data("a"), data("a"), data("b"), data("b")) + .skipEqualData().toList() + assertEquals(listOf("a", "b"), out.map { (it as StoreResult.Data).value }) + } + + @Test + fun ageIsExcludedFromTheComparison(): TestResult = runTest { + val out = flowOf(data("a", age = 0.milliseconds), data("a", age = 250.milliseconds)) + .skipEqualData().toList() + assertEquals(1, out.size) + } + + @Test + fun flagChangesAreEmitted(): TestResult = runTest { + val out = flowOf(data("a"), data("a", refreshing = true), data("a", refreshing = true, stale = true)) + .skipEqualData().toList() + assertEquals(3, out.size) + } + + @Test + fun lifecycleSignalsAlwaysPass(): TestResult = runTest { + val error = TestStoreResults.error(TestStoreResults.fetchError("boom"), servedStale = true) + val out = flowOf( + TestStoreResults.loading(), TestStoreResults.loading(), + data("a"), TestStoreResults.revalidated(1.milliseconds), + TestStoreResults.revalidated(1.milliseconds), error, error, + ).skipEqualData().toList() + assertEquals(7, out.size) + } + + @Test + fun dataSeparatedByLifecycleSignalReEmits(): TestResult = runTest { + val out = flowOf(data("a"), TestStoreResults.loading(), data("a")) + .skipEqualData().toList() + assertEquals(3, out.size) + } + + @Test + fun customValueEquivalenceIsHonored(): TestResult = runTest { + val out = flowOf(data("a"), data("A"), data("b")) + .skipEqualData { x, y -> x.equals(y, ignoreCase = true) }.toList() + assertEquals(listOf("a", "b"), out.map { (it as StoreResult.Data).value }) + } +} diff --git a/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/StoreResultMutationPolicyTest.kt b/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/StoreResultMutationPolicyTest.kt new file mode 100644 index 000000000..bcd7e5b7f --- /dev/null +++ b/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/StoreResultMutationPolicyTest.kt @@ -0,0 +1,59 @@ +@file:OptIn(ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.compose + +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.testing.TestStoreResults +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds + +class StoreResultMutationPolicyTest { + private val policy = storeResultMutationPolicy() + private fun data( + value: String, + refreshing: Boolean = false, + age: Long = 0, + origin: Origin = Origin.FETCHER, + ) = TestStoreResults.data( + value = value, origin = origin, age = age.milliseconds, + isStale = false, refreshing = refreshing, + ) + + @Test fun equivalentForStructurallyEqualDataIgnoringAge() = + assertTrue(policy.equivalent(data("a", age = 0), data("a", age = 90))) + + @Test fun notEquivalentWhenFlagsDiffer() = + assertFalse(policy.equivalent(data("a"), data("a", refreshing = true))) + + @Test fun notEquivalentAcrossVariants() = + assertFalse(policy.equivalent(data("a"), TestStoreResults.loading())) + + @Test fun distinctErrorInstancesAreNotEquivalent() { + val e1 = TestStoreResults.error(TestStoreResults.fetchError("x"), servedStale = false) + val e2 = TestStoreResults.error(TestStoreResults.fetchError("x"), servedStale = false) + assertFalse(policy.equivalent(e1, e2)) + } + + @Test fun sameInstanceIsEquivalent() { + val loading = TestStoreResults.loading() + assertTrue(policy.equivalent(loading, loading)) + } + + /** + * The memory-snapshot-then-fetch-commit sequence emits the same value under two origins; + * collapsing those would hide the origin transition from readers. + */ + @Test fun notEquivalentWhenOriginDiffers() = + assertFalse(policy.equivalent(data("a", origin = Origin.MEMORY), data("a", origin = Origin.FETCHER))) + + @Test fun customValueEquivalenceIsHonored() { + val caseInsensitive = storeResultMutationPolicy { x, y -> x.equals(y, ignoreCase = true) } + assertTrue(caseInsensitive.equivalent(data("a"), data("A"))) + assertFalse(caseInsensitive.equivalent(data("a"), data("b"))) + // The default policy must NOT treat these as equivalent — proving the custom one was used. + assertFalse(policy.equivalent(data("a"), data("A"))) + } +} diff --git a/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/docs/PendingWriteUiDocsSnippet.kt b/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/docs/PendingWriteUiDocsSnippet.kt new file mode 100644 index 000000000..59df5d26f --- /dev/null +++ b/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/docs/PendingWriteUiDocsSnippet.kt @@ -0,0 +1,22 @@ +package org.mobilenativefoundation.store6.compose.docs + +// docs:snippet:mutations-pending-write-ui-badges +import androidx.compose.runtime.Composable +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreResult + +@Composable +fun WriteBadges( + result: StoreResult, + saving: @Composable () -> Unit, + stale: @Composable () -> Unit, +) { + when (result) { + is StoreResult.Data -> { + if (result.origin == Origin.OVERLAY) saving() + if (result.isStale) stale() + } + else -> Unit + } +} +// docs:snippet:end diff --git a/store6-compose/stability/store6-stability.conf b/store6-compose/stability/store6-stability.conf new file mode 100644 index 000000000..bc811029f --- /dev/null +++ b/store6-compose/stability/store6-stability.conf @@ -0,0 +1,9 @@ +// Store v6 stability configuration for the Compose compiler. +// Core public value types are deeply immutable by construction (vals only, gated by BCV dumps). +// Their equals is identity (documented behavioral contract), so treating them as stable never +// wrongly skips: an equals comparison can only report "unchanged" for the same instance. +// Add this file via composeCompiler.stabilityConfigurationFiles (Compose compiler 1.5.5+ / +// Kotlin 2.x compose plugin) if you are on an older baseline or want skipping without +// strong skipping's instance comparison. +org.mobilenativefoundation.store6.core.* +org.mobilenativefoundation.store6.core.seam.* diff --git a/store6-core/api/android/store6-core.api b/store6-core/api/android/store6-core.api new file mode 100644 index 000000000..fa9ffa6cc --- /dev/null +++ b/store6-core/api/android/store6-core.api @@ -0,0 +1,344 @@ +public abstract interface annotation class org/mobilenativefoundation/store6/core/DelicateStoreApi : java/lang/annotation/Annotation { +} + +public abstract interface annotation class org/mobilenativefoundation/store6/core/ExperimentalStoreApi : java/lang/annotation/Annotation { +} + +public abstract interface class org/mobilenativefoundation/store6/core/Freshness { +} + +public final class org/mobilenativefoundation/store6/core/Freshness$CachedOrFetch : org/mobilenativefoundation/store6/core/Freshness { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/core/Freshness$CachedOrFetch; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/Freshness$LocalOnly : org/mobilenativefoundation/store6/core/Freshness { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/core/Freshness$LocalOnly; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/Freshness$MaxAge : org/mobilenativefoundation/store6/core/Freshness { + public synthetic fun (JLkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getNotOlderThan-UwyO8pc ()J +} + +public final class org/mobilenativefoundation/store6/core/Freshness$MustBeFresh : org/mobilenativefoundation/store6/core/Freshness { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/core/Freshness$MustBeFresh; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/Freshness$StaleIfError : org/mobilenativefoundation/store6/core/Freshness { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/core/Freshness$StaleIfError; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface annotation class org/mobilenativefoundation/store6/core/InternalStoreApi : java/lang/annotation/Annotation { +} + +public final class org/mobilenativefoundation/store6/core/Origin : java/lang/Enum { + public static final field FETCHER Lorg/mobilenativefoundation/store6/core/Origin; + public static final field MEMORY Lorg/mobilenativefoundation/store6/core/Origin; + public static final field OVERLAY Lorg/mobilenativefoundation/store6/core/Origin; + public static final field SOT Lorg/mobilenativefoundation/store6/core/Origin; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/mobilenativefoundation/store6/core/Origin; + public static fun values ()[Lorg/mobilenativefoundation/store6/core/Origin; +} + +public abstract interface class org/mobilenativefoundation/store6/core/Store { + public abstract fun clear (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun clearAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun clearNamespace (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun close ()V + public abstract fun get (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Freshness;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun invalidate (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun invalidateAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun invalidateNamespace (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun stream (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Freshness;)Lkotlinx/coroutines/flow/Flow; +} + +public final class org/mobilenativefoundation/store6/core/Store$DefaultImpls { + public static synthetic fun get$default (Lorg/mobilenativefoundation/store6/core/Store;Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Freshness;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static synthetic fun stream$default (Lorg/mobilenativefoundation/store6/core/Store;Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Freshness;ILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; +} + +public final class org/mobilenativefoundation/store6/core/StoreBuilder { + public final fun bookkeeper (Lorg/mobilenativefoundation/store6/core/seam/Bookkeeper;)V + public final fun fetcher (Lkotlin/jvm/functions/Function2;)V + public final fun fetcher (Lorg/mobilenativefoundation/store6/core/seam/Fetcher;)V + public final fun fetcherOfResult (Lkotlin/jvm/functions/Function2;)V + public final fun freshnessValidator (Lorg/mobilenativefoundation/store6/core/seam/FreshnessValidator;)V + public final fun maxIdleKeys (I)V + public final fun overlay (Lorg/mobilenativefoundation/store6/core/seam/Overlay;)V + public final fun persistence (Lorg/mobilenativefoundation/store6/core/seam/SourceOfTruth;)V + public final fun telemetry (Lorg/mobilenativefoundation/store6/core/seam/StoreTelemetry;)V + public final fun wallClock (Lorg/mobilenativefoundation/store6/core/seam/WallClock;)V +} + +public final class org/mobilenativefoundation/store6/core/StoreBuilderKt { + public static final fun store (Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store6/core/Store; +} + +public abstract class org/mobilenativefoundation/store6/core/StoreError { +} + +public final class org/mobilenativefoundation/store6/core/StoreError$Conflict : org/mobilenativefoundation/store6/core/StoreError { + public final fun getMessage ()Ljava/lang/String; + public final fun getServerMeta ()Lorg/mobilenativefoundation/store6/core/StoreMeta; +} + +public final class org/mobilenativefoundation/store6/core/StoreError$Conversion : org/mobilenativefoundation/store6/core/StoreError { + public final fun getCause ()Ljava/lang/Throwable; + public final fun getMessage ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/StoreError$Fetch : org/mobilenativefoundation/store6/core/StoreError { + public final fun getCause ()Ljava/lang/Throwable; + public final fun getMessage ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/StoreError$FreshnessUnsatisfiable : org/mobilenativefoundation/store6/core/StoreError { + public final fun getMessage ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/StoreError$Missing : org/mobilenativefoundation/store6/core/StoreError { + public final fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; + public final fun getMessage ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/StoreError$Persistence : org/mobilenativefoundation/store6/core/StoreError { + public final fun getCause ()Ljava/lang/Throwable; + public final fun getMessage ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/StoreException : java/lang/RuntimeException { + public final fun getError ()Lorg/mobilenativefoundation/store6/core/StoreError; +} + +public abstract interface class org/mobilenativefoundation/store6/core/StoreKey { + public abstract fun canonicalId ()Ljava/lang/String; + public abstract fun getNamespace ()Lorg/mobilenativefoundation/store6/core/StoreNamespace; +} + +public abstract interface class org/mobilenativefoundation/store6/core/StoreMeta { + public abstract fun getEtag ()Ljava/lang/String; + public abstract fun getWrittenAtEpochMillis ()J +} + +public final class org/mobilenativefoundation/store6/core/StoreNamespace { + public fun (Ljava/lang/String;)V + public final fun getValue ()Ljava/lang/String; +} + +public abstract interface class org/mobilenativefoundation/store6/core/StoreResult { +} + +public final class org/mobilenativefoundation/store6/core/StoreResult$Data : org/mobilenativefoundation/store6/core/StoreResult { + public final fun getAge-UwyO8pc ()J + public final fun getOrigin ()Lorg/mobilenativefoundation/store6/core/Origin; + public final fun getRefreshing ()Z + public final fun getValue ()Ljava/lang/Object; + public final fun isStale ()Z +} + +public final class org/mobilenativefoundation/store6/core/StoreResult$Error : org/mobilenativefoundation/store6/core/StoreResult { + public final fun getError ()Lorg/mobilenativefoundation/store6/core/StoreError; + public final fun getServedStale ()Z +} + +public final class org/mobilenativefoundation/store6/core/StoreResult$Loading : org/mobilenativefoundation/store6/core/StoreResult { +} + +public final class org/mobilenativefoundation/store6/core/StoreResult$Revalidated : org/mobilenativefoundation/store6/core/StoreResult { + public final fun getAge-UwyO8pc ()J +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/Bookkeeper { + public abstract fun advanceGlobalStaleWatermark (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun advanceStaleWatermark (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun forget (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun forgetAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun forgetNamespace (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun markStale (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun recordFailure (Lorg/mobilenativefoundation/store6/core/StoreKey;JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun recordSuccess (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/StoreMeta;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun status (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/FetchPlan { +} + +public final class org/mobilenativefoundation/store6/core/seam/FetchPlan$Conditional : org/mobilenativefoundation/store6/core/seam/FetchPlan { + public fun (Ljava/lang/String;Z)V + public final fun getEtag ()Ljava/lang/String; + public final fun getServesResidentWhileFetching ()Z +} + +public final class org/mobilenativefoundation/store6/core/seam/FetchPlan$Fetch : org/mobilenativefoundation/store6/core/seam/FetchPlan { + public fun (Z)V + public final fun getServesResidentWhileFetching ()Z +} + +public final class org/mobilenativefoundation/store6/core/seam/FetchPlan$Skip : org/mobilenativefoundation/store6/core/seam/FetchPlan { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/core/seam/FetchPlan$Skip; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/Fetcher { + public abstract fun fetch (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/FetcherResult { +} + +public final class org/mobilenativefoundation/store6/core/seam/FetcherResult$Deleted : org/mobilenativefoundation/store6/core/seam/FetcherResult { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/core/seam/FetcherResult$Deleted; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/seam/FetcherResult$Error : org/mobilenativefoundation/store6/core/seam/FetcherResult { + public fun (Ljava/lang/Throwable;)V + public final fun getCause ()Ljava/lang/Throwable; +} + +public final class org/mobilenativefoundation/store6/core/seam/FetcherResult$NotModified : org/mobilenativefoundation/store6/core/seam/FetcherResult { + public fun ()V + public fun (Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getEtag ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/seam/FetcherResult$Success : org/mobilenativefoundation/store6/core/seam/FetcherResult { + public fun (Ljava/lang/Object;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/Object;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getEtag ()Ljava/lang/String; + public final fun getValue ()Ljava/lang/Object; +} + +public final class org/mobilenativefoundation/store6/core/seam/FreshnessContext { + public fun (ZLorg/mobilenativefoundation/store6/core/StoreMeta;ZLorg/mobilenativefoundation/store6/core/Freshness;JLorg/mobilenativefoundation/store6/core/seam/KeyStatus;)V + public synthetic fun (ZLorg/mobilenativefoundation/store6/core/StoreMeta;ZLorg/mobilenativefoundation/store6/core/Freshness;JLorg/mobilenativefoundation/store6/core/seam/KeyStatus;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getEpochStale ()Z + public final fun getFreshness ()Lorg/mobilenativefoundation/store6/core/Freshness; + public final fun getHasResidentValue ()Z + public final fun getMeta ()Lorg/mobilenativefoundation/store6/core/StoreMeta; + public final fun getNowEpochMillis ()J + public final fun getStatus ()Lorg/mobilenativefoundation/store6/core/seam/KeyStatus; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/FreshnessValidator { + public abstract fun plan (Lorg/mobilenativefoundation/store6/core/seam/FreshnessContext;)Lorg/mobilenativefoundation/store6/core/seam/FetchPlan; +} + +public abstract class org/mobilenativefoundation/store6/core/seam/KeyEvents { + public abstract fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; +} + +public final class org/mobilenativefoundation/store6/core/seam/KeyEvents$Deleted : org/mobilenativefoundation/store6/core/seam/KeyEvents { + public fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; +} + +public final class org/mobilenativefoundation/store6/core/seam/KeyEvents$Invalidated : org/mobilenativefoundation/store6/core/seam/KeyEvents { + public fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; +} + +public final class org/mobilenativefoundation/store6/core/seam/KeyEvents$Written : org/mobilenativefoundation/store6/core/seam/KeyEvents { + public fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; + public final fun getOrigin ()Lorg/mobilenativefoundation/store6/core/Origin; +} + +public final class org/mobilenativefoundation/store6/core/seam/KeyStatus { + public fun (Lorg/mobilenativefoundation/store6/core/StoreMeta;Ljava/lang/Long;Ljava/lang/Long;IZ)V + public final fun getConsecutiveFailures ()I + public final fun getDurablyStale ()Z + public final fun getLastFailureAtEpochMillis ()Ljava/lang/Long; + public final fun getLastSuccessSequence ()Ljava/lang/Long; + public final fun getMeta ()Lorg/mobilenativefoundation/store6/core/StoreMeta; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/Overlay { + public abstract fun apply (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/Object;)Ljava/lang/Object; + public abstract fun getChanges ()Lkotlinx/coroutines/flow/Flow; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/SourceOfTruth { + public abstract fun delete (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteNamespace (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun reader (Lorg/mobilenativefoundation/store6/core/StoreKey;)Lkotlinx/coroutines/flow/Flow; + public abstract fun write (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class org/mobilenativefoundation/store6/core/seam/StoreResults { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/core/seam/StoreResults; + public final fun conflict (Lorg/mobilenativefoundation/store6/core/StoreMeta;Ljava/lang/String;)Lorg/mobilenativefoundation/store6/core/StoreError$Conflict; + public final fun conversionError (Ljava/lang/String;Ljava/lang/Throwable;)Lorg/mobilenativefoundation/store6/core/StoreError$Conversion; + public static synthetic fun conversionError$default (Lorg/mobilenativefoundation/store6/core/seam/StoreResults;Ljava/lang/String;Ljava/lang/Throwable;ILjava/lang/Object;)Lorg/mobilenativefoundation/store6/core/StoreError$Conversion; + public final fun data-1Y68eR8 (Ljava/lang/Object;Lorg/mobilenativefoundation/store6/core/Origin;JZZ)Lorg/mobilenativefoundation/store6/core/StoreResult$Data; + public final fun error (Lorg/mobilenativefoundation/store6/core/StoreError;Z)Lorg/mobilenativefoundation/store6/core/StoreResult$Error; + public final fun exception (Lorg/mobilenativefoundation/store6/core/StoreError;Ljava/lang/Throwable;)Lorg/mobilenativefoundation/store6/core/StoreException; + public static synthetic fun exception$default (Lorg/mobilenativefoundation/store6/core/seam/StoreResults;Lorg/mobilenativefoundation/store6/core/StoreError;Ljava/lang/Throwable;ILjava/lang/Object;)Lorg/mobilenativefoundation/store6/core/StoreException; + public final fun fetchError (Ljava/lang/String;Ljava/lang/Throwable;)Lorg/mobilenativefoundation/store6/core/StoreError$Fetch; + public static synthetic fun fetchError$default (Lorg/mobilenativefoundation/store6/core/seam/StoreResults;Ljava/lang/String;Ljava/lang/Throwable;ILjava/lang/Object;)Lorg/mobilenativefoundation/store6/core/StoreError$Fetch; + public final fun freshnessUnsatisfiable (Ljava/lang/String;)Lorg/mobilenativefoundation/store6/core/StoreError$FreshnessUnsatisfiable; + public final fun loading ()Lorg/mobilenativefoundation/store6/core/StoreResult$Loading; + public final fun missing (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/String;)Lorg/mobilenativefoundation/store6/core/StoreError$Missing; + public final fun persistenceError (Ljava/lang/String;Ljava/lang/Throwable;)Lorg/mobilenativefoundation/store6/core/StoreError$Persistence; + public static synthetic fun persistenceError$default (Lorg/mobilenativefoundation/store6/core/seam/StoreResults;Ljava/lang/String;Ljava/lang/Throwable;ILjava/lang/Object;)Lorg/mobilenativefoundation/store6/core/StoreError$Persistence; + public final fun revalidated-LRDsOJo (J)Lorg/mobilenativefoundation/store6/core/StoreResult$Revalidated; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/StoreRuntime { + public abstract fun getKeyEvents ()Lkotlinx/coroutines/flow/Flow; + public abstract fun getTelemetry ()Lorg/mobilenativefoundation/store6/core/seam/StoreTelemetry; + public abstract fun getWriteHandle ()Lorg/mobilenativefoundation/store6/core/seam/StoreWriteHandle; +} + +public final class org/mobilenativefoundation/store6/core/seam/StoreRuntimeKt { + public static final fun runtime (Lorg/mobilenativefoundation/store6/core/Store;)Lorg/mobilenativefoundation/store6/core/seam/StoreRuntime; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/StoreTelemetry { + public abstract fun onCleared (Lorg/mobilenativefoundation/store6/core/StoreKey;)V + public abstract fun onFetchFailed-SxA4cEA (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/StoreError;J)V + public abstract fun onFetchStarted (Lorg/mobilenativefoundation/store6/core/StoreKey;)V + public abstract fun onFetchSucceeded-HG0u8IE (Lorg/mobilenativefoundation/store6/core/StoreKey;J)V + public abstract fun onInvalidated (Lorg/mobilenativefoundation/store6/core/StoreKey;)V + public abstract fun onServe (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Origin;)V +} + +public final class org/mobilenativefoundation/store6/core/seam/StoreTelemetry$DefaultImpls { + public static fun onCleared (Lorg/mobilenativefoundation/store6/core/seam/StoreTelemetry;Lorg/mobilenativefoundation/store6/core/StoreKey;)V + public static fun onFetchFailed-SxA4cEA (Lorg/mobilenativefoundation/store6/core/seam/StoreTelemetry;Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/StoreError;J)V + public static fun onFetchStarted (Lorg/mobilenativefoundation/store6/core/seam/StoreTelemetry;Lorg/mobilenativefoundation/store6/core/StoreKey;)V + public static fun onFetchSucceeded-HG0u8IE (Lorg/mobilenativefoundation/store6/core/seam/StoreTelemetry;Lorg/mobilenativefoundation/store6/core/StoreKey;J)V + public static fun onInvalidated (Lorg/mobilenativefoundation/store6/core/seam/StoreTelemetry;Lorg/mobilenativefoundation/store6/core/StoreKey;)V + public static fun onServe (Lorg/mobilenativefoundation/store6/core/seam/StoreTelemetry;Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Origin;)V +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/StoreWriteHandle { + public abstract fun apply (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun confirmFresh (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun markStale (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/TransactionalSourceOfTruth : org/mobilenativefoundation/store6/core/seam/SourceOfTruth { + public abstract fun withTransaction (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/WallClock { + public abstract fun nowEpochMillis ()J +} + diff --git a/store6-core/api/jvm/store6-core.api b/store6-core/api/jvm/store6-core.api new file mode 100644 index 000000000..fa9ffa6cc --- /dev/null +++ b/store6-core/api/jvm/store6-core.api @@ -0,0 +1,344 @@ +public abstract interface annotation class org/mobilenativefoundation/store6/core/DelicateStoreApi : java/lang/annotation/Annotation { +} + +public abstract interface annotation class org/mobilenativefoundation/store6/core/ExperimentalStoreApi : java/lang/annotation/Annotation { +} + +public abstract interface class org/mobilenativefoundation/store6/core/Freshness { +} + +public final class org/mobilenativefoundation/store6/core/Freshness$CachedOrFetch : org/mobilenativefoundation/store6/core/Freshness { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/core/Freshness$CachedOrFetch; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/Freshness$LocalOnly : org/mobilenativefoundation/store6/core/Freshness { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/core/Freshness$LocalOnly; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/Freshness$MaxAge : org/mobilenativefoundation/store6/core/Freshness { + public synthetic fun (JLkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getNotOlderThan-UwyO8pc ()J +} + +public final class org/mobilenativefoundation/store6/core/Freshness$MustBeFresh : org/mobilenativefoundation/store6/core/Freshness { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/core/Freshness$MustBeFresh; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/Freshness$StaleIfError : org/mobilenativefoundation/store6/core/Freshness { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/core/Freshness$StaleIfError; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface annotation class org/mobilenativefoundation/store6/core/InternalStoreApi : java/lang/annotation/Annotation { +} + +public final class org/mobilenativefoundation/store6/core/Origin : java/lang/Enum { + public static final field FETCHER Lorg/mobilenativefoundation/store6/core/Origin; + public static final field MEMORY Lorg/mobilenativefoundation/store6/core/Origin; + public static final field OVERLAY Lorg/mobilenativefoundation/store6/core/Origin; + public static final field SOT Lorg/mobilenativefoundation/store6/core/Origin; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/mobilenativefoundation/store6/core/Origin; + public static fun values ()[Lorg/mobilenativefoundation/store6/core/Origin; +} + +public abstract interface class org/mobilenativefoundation/store6/core/Store { + public abstract fun clear (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun clearAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun clearNamespace (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun close ()V + public abstract fun get (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Freshness;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun invalidate (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun invalidateAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun invalidateNamespace (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun stream (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Freshness;)Lkotlinx/coroutines/flow/Flow; +} + +public final class org/mobilenativefoundation/store6/core/Store$DefaultImpls { + public static synthetic fun get$default (Lorg/mobilenativefoundation/store6/core/Store;Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Freshness;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static synthetic fun stream$default (Lorg/mobilenativefoundation/store6/core/Store;Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Freshness;ILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; +} + +public final class org/mobilenativefoundation/store6/core/StoreBuilder { + public final fun bookkeeper (Lorg/mobilenativefoundation/store6/core/seam/Bookkeeper;)V + public final fun fetcher (Lkotlin/jvm/functions/Function2;)V + public final fun fetcher (Lorg/mobilenativefoundation/store6/core/seam/Fetcher;)V + public final fun fetcherOfResult (Lkotlin/jvm/functions/Function2;)V + public final fun freshnessValidator (Lorg/mobilenativefoundation/store6/core/seam/FreshnessValidator;)V + public final fun maxIdleKeys (I)V + public final fun overlay (Lorg/mobilenativefoundation/store6/core/seam/Overlay;)V + public final fun persistence (Lorg/mobilenativefoundation/store6/core/seam/SourceOfTruth;)V + public final fun telemetry (Lorg/mobilenativefoundation/store6/core/seam/StoreTelemetry;)V + public final fun wallClock (Lorg/mobilenativefoundation/store6/core/seam/WallClock;)V +} + +public final class org/mobilenativefoundation/store6/core/StoreBuilderKt { + public static final fun store (Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store6/core/Store; +} + +public abstract class org/mobilenativefoundation/store6/core/StoreError { +} + +public final class org/mobilenativefoundation/store6/core/StoreError$Conflict : org/mobilenativefoundation/store6/core/StoreError { + public final fun getMessage ()Ljava/lang/String; + public final fun getServerMeta ()Lorg/mobilenativefoundation/store6/core/StoreMeta; +} + +public final class org/mobilenativefoundation/store6/core/StoreError$Conversion : org/mobilenativefoundation/store6/core/StoreError { + public final fun getCause ()Ljava/lang/Throwable; + public final fun getMessage ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/StoreError$Fetch : org/mobilenativefoundation/store6/core/StoreError { + public final fun getCause ()Ljava/lang/Throwable; + public final fun getMessage ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/StoreError$FreshnessUnsatisfiable : org/mobilenativefoundation/store6/core/StoreError { + public final fun getMessage ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/StoreError$Missing : org/mobilenativefoundation/store6/core/StoreError { + public final fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; + public final fun getMessage ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/StoreError$Persistence : org/mobilenativefoundation/store6/core/StoreError { + public final fun getCause ()Ljava/lang/Throwable; + public final fun getMessage ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/StoreException : java/lang/RuntimeException { + public final fun getError ()Lorg/mobilenativefoundation/store6/core/StoreError; +} + +public abstract interface class org/mobilenativefoundation/store6/core/StoreKey { + public abstract fun canonicalId ()Ljava/lang/String; + public abstract fun getNamespace ()Lorg/mobilenativefoundation/store6/core/StoreNamespace; +} + +public abstract interface class org/mobilenativefoundation/store6/core/StoreMeta { + public abstract fun getEtag ()Ljava/lang/String; + public abstract fun getWrittenAtEpochMillis ()J +} + +public final class org/mobilenativefoundation/store6/core/StoreNamespace { + public fun (Ljava/lang/String;)V + public final fun getValue ()Ljava/lang/String; +} + +public abstract interface class org/mobilenativefoundation/store6/core/StoreResult { +} + +public final class org/mobilenativefoundation/store6/core/StoreResult$Data : org/mobilenativefoundation/store6/core/StoreResult { + public final fun getAge-UwyO8pc ()J + public final fun getOrigin ()Lorg/mobilenativefoundation/store6/core/Origin; + public final fun getRefreshing ()Z + public final fun getValue ()Ljava/lang/Object; + public final fun isStale ()Z +} + +public final class org/mobilenativefoundation/store6/core/StoreResult$Error : org/mobilenativefoundation/store6/core/StoreResult { + public final fun getError ()Lorg/mobilenativefoundation/store6/core/StoreError; + public final fun getServedStale ()Z +} + +public final class org/mobilenativefoundation/store6/core/StoreResult$Loading : org/mobilenativefoundation/store6/core/StoreResult { +} + +public final class org/mobilenativefoundation/store6/core/StoreResult$Revalidated : org/mobilenativefoundation/store6/core/StoreResult { + public final fun getAge-UwyO8pc ()J +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/Bookkeeper { + public abstract fun advanceGlobalStaleWatermark (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun advanceStaleWatermark (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun forget (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun forgetAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun forgetNamespace (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun markStale (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun recordFailure (Lorg/mobilenativefoundation/store6/core/StoreKey;JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun recordSuccess (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/StoreMeta;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun status (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/FetchPlan { +} + +public final class org/mobilenativefoundation/store6/core/seam/FetchPlan$Conditional : org/mobilenativefoundation/store6/core/seam/FetchPlan { + public fun (Ljava/lang/String;Z)V + public final fun getEtag ()Ljava/lang/String; + public final fun getServesResidentWhileFetching ()Z +} + +public final class org/mobilenativefoundation/store6/core/seam/FetchPlan$Fetch : org/mobilenativefoundation/store6/core/seam/FetchPlan { + public fun (Z)V + public final fun getServesResidentWhileFetching ()Z +} + +public final class org/mobilenativefoundation/store6/core/seam/FetchPlan$Skip : org/mobilenativefoundation/store6/core/seam/FetchPlan { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/core/seam/FetchPlan$Skip; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/Fetcher { + public abstract fun fetch (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/FetcherResult { +} + +public final class org/mobilenativefoundation/store6/core/seam/FetcherResult$Deleted : org/mobilenativefoundation/store6/core/seam/FetcherResult { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/core/seam/FetcherResult$Deleted; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/seam/FetcherResult$Error : org/mobilenativefoundation/store6/core/seam/FetcherResult { + public fun (Ljava/lang/Throwable;)V + public final fun getCause ()Ljava/lang/Throwable; +} + +public final class org/mobilenativefoundation/store6/core/seam/FetcherResult$NotModified : org/mobilenativefoundation/store6/core/seam/FetcherResult { + public fun ()V + public fun (Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getEtag ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/seam/FetcherResult$Success : org/mobilenativefoundation/store6/core/seam/FetcherResult { + public fun (Ljava/lang/Object;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/Object;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getEtag ()Ljava/lang/String; + public final fun getValue ()Ljava/lang/Object; +} + +public final class org/mobilenativefoundation/store6/core/seam/FreshnessContext { + public fun (ZLorg/mobilenativefoundation/store6/core/StoreMeta;ZLorg/mobilenativefoundation/store6/core/Freshness;JLorg/mobilenativefoundation/store6/core/seam/KeyStatus;)V + public synthetic fun (ZLorg/mobilenativefoundation/store6/core/StoreMeta;ZLorg/mobilenativefoundation/store6/core/Freshness;JLorg/mobilenativefoundation/store6/core/seam/KeyStatus;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getEpochStale ()Z + public final fun getFreshness ()Lorg/mobilenativefoundation/store6/core/Freshness; + public final fun getHasResidentValue ()Z + public final fun getMeta ()Lorg/mobilenativefoundation/store6/core/StoreMeta; + public final fun getNowEpochMillis ()J + public final fun getStatus ()Lorg/mobilenativefoundation/store6/core/seam/KeyStatus; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/FreshnessValidator { + public abstract fun plan (Lorg/mobilenativefoundation/store6/core/seam/FreshnessContext;)Lorg/mobilenativefoundation/store6/core/seam/FetchPlan; +} + +public abstract class org/mobilenativefoundation/store6/core/seam/KeyEvents { + public abstract fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; +} + +public final class org/mobilenativefoundation/store6/core/seam/KeyEvents$Deleted : org/mobilenativefoundation/store6/core/seam/KeyEvents { + public fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; +} + +public final class org/mobilenativefoundation/store6/core/seam/KeyEvents$Invalidated : org/mobilenativefoundation/store6/core/seam/KeyEvents { + public fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; +} + +public final class org/mobilenativefoundation/store6/core/seam/KeyEvents$Written : org/mobilenativefoundation/store6/core/seam/KeyEvents { + public fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; + public final fun getOrigin ()Lorg/mobilenativefoundation/store6/core/Origin; +} + +public final class org/mobilenativefoundation/store6/core/seam/KeyStatus { + public fun (Lorg/mobilenativefoundation/store6/core/StoreMeta;Ljava/lang/Long;Ljava/lang/Long;IZ)V + public final fun getConsecutiveFailures ()I + public final fun getDurablyStale ()Z + public final fun getLastFailureAtEpochMillis ()Ljava/lang/Long; + public final fun getLastSuccessSequence ()Ljava/lang/Long; + public final fun getMeta ()Lorg/mobilenativefoundation/store6/core/StoreMeta; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/Overlay { + public abstract fun apply (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/Object;)Ljava/lang/Object; + public abstract fun getChanges ()Lkotlinx/coroutines/flow/Flow; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/SourceOfTruth { + public abstract fun delete (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteNamespace (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun reader (Lorg/mobilenativefoundation/store6/core/StoreKey;)Lkotlinx/coroutines/flow/Flow; + public abstract fun write (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class org/mobilenativefoundation/store6/core/seam/StoreResults { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/core/seam/StoreResults; + public final fun conflict (Lorg/mobilenativefoundation/store6/core/StoreMeta;Ljava/lang/String;)Lorg/mobilenativefoundation/store6/core/StoreError$Conflict; + public final fun conversionError (Ljava/lang/String;Ljava/lang/Throwable;)Lorg/mobilenativefoundation/store6/core/StoreError$Conversion; + public static synthetic fun conversionError$default (Lorg/mobilenativefoundation/store6/core/seam/StoreResults;Ljava/lang/String;Ljava/lang/Throwable;ILjava/lang/Object;)Lorg/mobilenativefoundation/store6/core/StoreError$Conversion; + public final fun data-1Y68eR8 (Ljava/lang/Object;Lorg/mobilenativefoundation/store6/core/Origin;JZZ)Lorg/mobilenativefoundation/store6/core/StoreResult$Data; + public final fun error (Lorg/mobilenativefoundation/store6/core/StoreError;Z)Lorg/mobilenativefoundation/store6/core/StoreResult$Error; + public final fun exception (Lorg/mobilenativefoundation/store6/core/StoreError;Ljava/lang/Throwable;)Lorg/mobilenativefoundation/store6/core/StoreException; + public static synthetic fun exception$default (Lorg/mobilenativefoundation/store6/core/seam/StoreResults;Lorg/mobilenativefoundation/store6/core/StoreError;Ljava/lang/Throwable;ILjava/lang/Object;)Lorg/mobilenativefoundation/store6/core/StoreException; + public final fun fetchError (Ljava/lang/String;Ljava/lang/Throwable;)Lorg/mobilenativefoundation/store6/core/StoreError$Fetch; + public static synthetic fun fetchError$default (Lorg/mobilenativefoundation/store6/core/seam/StoreResults;Ljava/lang/String;Ljava/lang/Throwable;ILjava/lang/Object;)Lorg/mobilenativefoundation/store6/core/StoreError$Fetch; + public final fun freshnessUnsatisfiable (Ljava/lang/String;)Lorg/mobilenativefoundation/store6/core/StoreError$FreshnessUnsatisfiable; + public final fun loading ()Lorg/mobilenativefoundation/store6/core/StoreResult$Loading; + public final fun missing (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/String;)Lorg/mobilenativefoundation/store6/core/StoreError$Missing; + public final fun persistenceError (Ljava/lang/String;Ljava/lang/Throwable;)Lorg/mobilenativefoundation/store6/core/StoreError$Persistence; + public static synthetic fun persistenceError$default (Lorg/mobilenativefoundation/store6/core/seam/StoreResults;Ljava/lang/String;Ljava/lang/Throwable;ILjava/lang/Object;)Lorg/mobilenativefoundation/store6/core/StoreError$Persistence; + public final fun revalidated-LRDsOJo (J)Lorg/mobilenativefoundation/store6/core/StoreResult$Revalidated; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/StoreRuntime { + public abstract fun getKeyEvents ()Lkotlinx/coroutines/flow/Flow; + public abstract fun getTelemetry ()Lorg/mobilenativefoundation/store6/core/seam/StoreTelemetry; + public abstract fun getWriteHandle ()Lorg/mobilenativefoundation/store6/core/seam/StoreWriteHandle; +} + +public final class org/mobilenativefoundation/store6/core/seam/StoreRuntimeKt { + public static final fun runtime (Lorg/mobilenativefoundation/store6/core/Store;)Lorg/mobilenativefoundation/store6/core/seam/StoreRuntime; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/StoreTelemetry { + public abstract fun onCleared (Lorg/mobilenativefoundation/store6/core/StoreKey;)V + public abstract fun onFetchFailed-SxA4cEA (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/StoreError;J)V + public abstract fun onFetchStarted (Lorg/mobilenativefoundation/store6/core/StoreKey;)V + public abstract fun onFetchSucceeded-HG0u8IE (Lorg/mobilenativefoundation/store6/core/StoreKey;J)V + public abstract fun onInvalidated (Lorg/mobilenativefoundation/store6/core/StoreKey;)V + public abstract fun onServe (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Origin;)V +} + +public final class org/mobilenativefoundation/store6/core/seam/StoreTelemetry$DefaultImpls { + public static fun onCleared (Lorg/mobilenativefoundation/store6/core/seam/StoreTelemetry;Lorg/mobilenativefoundation/store6/core/StoreKey;)V + public static fun onFetchFailed-SxA4cEA (Lorg/mobilenativefoundation/store6/core/seam/StoreTelemetry;Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/StoreError;J)V + public static fun onFetchStarted (Lorg/mobilenativefoundation/store6/core/seam/StoreTelemetry;Lorg/mobilenativefoundation/store6/core/StoreKey;)V + public static fun onFetchSucceeded-HG0u8IE (Lorg/mobilenativefoundation/store6/core/seam/StoreTelemetry;Lorg/mobilenativefoundation/store6/core/StoreKey;J)V + public static fun onInvalidated (Lorg/mobilenativefoundation/store6/core/seam/StoreTelemetry;Lorg/mobilenativefoundation/store6/core/StoreKey;)V + public static fun onServe (Lorg/mobilenativefoundation/store6/core/seam/StoreTelemetry;Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Origin;)V +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/StoreWriteHandle { + public abstract fun apply (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun confirmFresh (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun markStale (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/TransactionalSourceOfTruth : org/mobilenativefoundation/store6/core/seam/SourceOfTruth { + public abstract fun withTransaction (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/WallClock { + public abstract fun nowEpochMillis ()J +} + diff --git a/store6-core/api/store6-core.klib.api b/store6-core/api/store6-core.klib.api new file mode 100644 index 000000000..05c34bcc4 --- /dev/null +++ b/store6-core/api/store6-core.klib.api @@ -0,0 +1,380 @@ +// Klib ABI Dump +// Targets: [iosArm64, iosSimulatorArm64, iosX64, js, linuxX64, macosArm64, mingwX64, tvosArm64, wasmJs, watchosArm64] +// Rendering settings: +// - Signature version: 2 +// - Show manifest properties: true +// - Show declarations: true + +// Library unique name: +open annotation class org.mobilenativefoundation.store6.core/DelicateStoreApi : kotlin/Annotation { // org.mobilenativefoundation.store6.core/DelicateStoreApi|null[0] + constructor () // org.mobilenativefoundation.store6.core/DelicateStoreApi.|(){}[0] +} + +open annotation class org.mobilenativefoundation.store6.core/ExperimentalStoreApi : kotlin/Annotation { // org.mobilenativefoundation.store6.core/ExperimentalStoreApi|null[0] + constructor () // org.mobilenativefoundation.store6.core/ExperimentalStoreApi.|(){}[0] +} + +open annotation class org.mobilenativefoundation.store6.core/InternalStoreApi : kotlin/Annotation { // org.mobilenativefoundation.store6.core/InternalStoreApi|null[0] + constructor () // org.mobilenativefoundation.store6.core/InternalStoreApi.|(){}[0] +} + +final enum class org.mobilenativefoundation.store6.core/Origin : kotlin/Enum { // org.mobilenativefoundation.store6.core/Origin|null[0] + enum entry FETCHER // org.mobilenativefoundation.store6.core/Origin.FETCHER|null[0] + enum entry MEMORY // org.mobilenativefoundation.store6.core/Origin.MEMORY|null[0] + enum entry OVERLAY // org.mobilenativefoundation.store6.core/Origin.OVERLAY|null[0] + enum entry SOT // org.mobilenativefoundation.store6.core/Origin.SOT|null[0] + + final val entries // org.mobilenativefoundation.store6.core/Origin.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // org.mobilenativefoundation.store6.core/Origin.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): org.mobilenativefoundation.store6.core/Origin // org.mobilenativefoundation.store6.core/Origin.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // org.mobilenativefoundation.store6.core/Origin.values|values#static(){}[0] +} + +abstract interface <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> org.mobilenativefoundation.store6.core.seam/Fetcher { // org.mobilenativefoundation.store6.core.seam/Fetcher|null[0] + abstract suspend fun fetch(#A, kotlin/String?): org.mobilenativefoundation.store6.core.seam/FetcherResult<#B> // org.mobilenativefoundation.store6.core.seam/Fetcher.fetch|fetch(1:0;kotlin.String?){}[0] +} + +abstract interface <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> org.mobilenativefoundation.store6.core.seam/Overlay { // org.mobilenativefoundation.store6.core.seam/Overlay|null[0] + abstract val changes // org.mobilenativefoundation.store6.core.seam/Overlay.changes|{}changes[0] + abstract fun (): kotlinx.coroutines.flow/Flow // org.mobilenativefoundation.store6.core.seam/Overlay.changes.|(){}[0] + + abstract fun apply(#A, #B?): #B? // org.mobilenativefoundation.store6.core.seam/Overlay.apply|apply(1:0;1:1?){}[0] +} + +abstract interface <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> org.mobilenativefoundation.store6.core.seam/SourceOfTruth { // org.mobilenativefoundation.store6.core.seam/SourceOfTruth|null[0] + abstract fun reader(#A): kotlinx.coroutines.flow/Flow<#B?> // org.mobilenativefoundation.store6.core.seam/SourceOfTruth.reader|reader(1:0){}[0] + abstract suspend fun delete(#A) // org.mobilenativefoundation.store6.core.seam/SourceOfTruth.delete|delete(1:0){}[0] + abstract suspend fun deleteAll() // org.mobilenativefoundation.store6.core.seam/SourceOfTruth.deleteAll|deleteAll(){}[0] + abstract suspend fun deleteNamespace(org.mobilenativefoundation.store6.core/StoreNamespace) // org.mobilenativefoundation.store6.core.seam/SourceOfTruth.deleteNamespace|deleteNamespace(org.mobilenativefoundation.store6.core.StoreNamespace){}[0] + abstract suspend fun write(#A, #B) // org.mobilenativefoundation.store6.core.seam/SourceOfTruth.write|write(1:0;1:1){}[0] +} + +abstract interface <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> org.mobilenativefoundation.store6.core.seam/StoreRuntime { // org.mobilenativefoundation.store6.core.seam/StoreRuntime|null[0] + abstract val keyEvents // org.mobilenativefoundation.store6.core.seam/StoreRuntime.keyEvents|{}keyEvents[0] + abstract fun (): kotlinx.coroutines.flow/Flow // org.mobilenativefoundation.store6.core.seam/StoreRuntime.keyEvents.|(){}[0] + abstract val telemetry // org.mobilenativefoundation.store6.core.seam/StoreRuntime.telemetry|{}telemetry[0] + abstract fun (): org.mobilenativefoundation.store6.core.seam/StoreTelemetry? // org.mobilenativefoundation.store6.core.seam/StoreRuntime.telemetry.|(){}[0] + abstract val writeHandle // org.mobilenativefoundation.store6.core.seam/StoreRuntime.writeHandle|{}writeHandle[0] + abstract fun (): org.mobilenativefoundation.store6.core.seam/StoreWriteHandle<#A, #B> // org.mobilenativefoundation.store6.core.seam/StoreRuntime.writeHandle.|(){}[0] +} + +abstract interface <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> org.mobilenativefoundation.store6.core.seam/StoreWriteHandle { // org.mobilenativefoundation.store6.core.seam/StoreWriteHandle|null[0] + abstract suspend fun apply(#A, #B) // org.mobilenativefoundation.store6.core.seam/StoreWriteHandle.apply|apply(1:0;1:1){}[0] + abstract suspend fun confirmFresh(#A, kotlin/String?) // org.mobilenativefoundation.store6.core.seam/StoreWriteHandle.confirmFresh|confirmFresh(1:0;kotlin.String?){}[0] + abstract suspend fun markStale(#A) // org.mobilenativefoundation.store6.core.seam/StoreWriteHandle.markStale|markStale(1:0){}[0] +} + +abstract interface <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> org.mobilenativefoundation.store6.core.seam/TransactionalSourceOfTruth : org.mobilenativefoundation.store6.core.seam/SourceOfTruth<#A, #B> { // org.mobilenativefoundation.store6.core.seam/TransactionalSourceOfTruth|null[0] + abstract suspend fun <#A1: kotlin/Any?> withTransaction(kotlin.coroutines/SuspendFunction0<#A1>): #A1 // org.mobilenativefoundation.store6.core.seam/TransactionalSourceOfTruth.withTransaction|withTransaction(kotlin.coroutines.SuspendFunction0<0:0>){0§}[0] +} + +abstract interface <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: out kotlin/Any> org.mobilenativefoundation.store6.core/Store { // org.mobilenativefoundation.store6.core/Store|null[0] + abstract fun close() // org.mobilenativefoundation.store6.core/Store.close|close(){}[0] + abstract fun stream(#A, org.mobilenativefoundation.store6.core/Freshness = ...): kotlinx.coroutines.flow/Flow> // org.mobilenativefoundation.store6.core/Store.stream|stream(1:0;org.mobilenativefoundation.store6.core.Freshness){}[0] + abstract suspend fun clear(#A) // org.mobilenativefoundation.store6.core/Store.clear|clear(1:0){}[0] + abstract suspend fun clearAll() // org.mobilenativefoundation.store6.core/Store.clearAll|clearAll(){}[0] + abstract suspend fun clearNamespace(org.mobilenativefoundation.store6.core/StoreNamespace) // org.mobilenativefoundation.store6.core/Store.clearNamespace|clearNamespace(org.mobilenativefoundation.store6.core.StoreNamespace){}[0] + abstract suspend fun get(#A, org.mobilenativefoundation.store6.core/Freshness = ...): #B // org.mobilenativefoundation.store6.core/Store.get|get(1:0;org.mobilenativefoundation.store6.core.Freshness){}[0] + abstract suspend fun invalidate(#A) // org.mobilenativefoundation.store6.core/Store.invalidate|invalidate(1:0){}[0] + abstract suspend fun invalidateAll() // org.mobilenativefoundation.store6.core/Store.invalidateAll|invalidateAll(){}[0] + abstract suspend fun invalidateNamespace(org.mobilenativefoundation.store6.core/StoreNamespace) // org.mobilenativefoundation.store6.core/Store.invalidateNamespace|invalidateNamespace(org.mobilenativefoundation.store6.core.StoreNamespace){}[0] +} + +abstract interface org.mobilenativefoundation.store6.core.seam/Bookkeeper { // org.mobilenativefoundation.store6.core.seam/Bookkeeper|null[0] + abstract suspend fun advanceGlobalStaleWatermark() // org.mobilenativefoundation.store6.core.seam/Bookkeeper.advanceGlobalStaleWatermark|advanceGlobalStaleWatermark(){}[0] + abstract suspend fun advanceStaleWatermark(org.mobilenativefoundation.store6.core/StoreNamespace) // org.mobilenativefoundation.store6.core.seam/Bookkeeper.advanceStaleWatermark|advanceStaleWatermark(org.mobilenativefoundation.store6.core.StoreNamespace){}[0] + abstract suspend fun forget(org.mobilenativefoundation.store6.core/StoreKey) // org.mobilenativefoundation.store6.core.seam/Bookkeeper.forget|forget(org.mobilenativefoundation.store6.core.StoreKey){}[0] + abstract suspend fun forgetAll() // org.mobilenativefoundation.store6.core.seam/Bookkeeper.forgetAll|forgetAll(){}[0] + abstract suspend fun forgetNamespace(org.mobilenativefoundation.store6.core/StoreNamespace) // org.mobilenativefoundation.store6.core.seam/Bookkeeper.forgetNamespace|forgetNamespace(org.mobilenativefoundation.store6.core.StoreNamespace){}[0] + abstract suspend fun markStale(org.mobilenativefoundation.store6.core/StoreKey) // org.mobilenativefoundation.store6.core.seam/Bookkeeper.markStale|markStale(org.mobilenativefoundation.store6.core.StoreKey){}[0] + abstract suspend fun recordFailure(org.mobilenativefoundation.store6.core/StoreKey, kotlin/Long) // org.mobilenativefoundation.store6.core.seam/Bookkeeper.recordFailure|recordFailure(org.mobilenativefoundation.store6.core.StoreKey;kotlin.Long){}[0] + abstract suspend fun recordSuccess(org.mobilenativefoundation.store6.core/StoreKey, org.mobilenativefoundation.store6.core/StoreMeta) // org.mobilenativefoundation.store6.core.seam/Bookkeeper.recordSuccess|recordSuccess(org.mobilenativefoundation.store6.core.StoreKey;org.mobilenativefoundation.store6.core.StoreMeta){}[0] + abstract suspend fun status(org.mobilenativefoundation.store6.core/StoreKey): org.mobilenativefoundation.store6.core.seam/KeyStatus? // org.mobilenativefoundation.store6.core.seam/Bookkeeper.status|status(org.mobilenativefoundation.store6.core.StoreKey){}[0] +} + +abstract interface org.mobilenativefoundation.store6.core.seam/FreshnessValidator { // org.mobilenativefoundation.store6.core.seam/FreshnessValidator|null[0] + abstract fun plan(org.mobilenativefoundation.store6.core.seam/FreshnessContext): org.mobilenativefoundation.store6.core.seam/FetchPlan // org.mobilenativefoundation.store6.core.seam/FreshnessValidator.plan|plan(org.mobilenativefoundation.store6.core.seam.FreshnessContext){}[0] +} + +abstract interface org.mobilenativefoundation.store6.core.seam/StoreTelemetry { // org.mobilenativefoundation.store6.core.seam/StoreTelemetry|null[0] + open fun onCleared(org.mobilenativefoundation.store6.core/StoreKey) // org.mobilenativefoundation.store6.core.seam/StoreTelemetry.onCleared|onCleared(org.mobilenativefoundation.store6.core.StoreKey){}[0] + open fun onFetchFailed(org.mobilenativefoundation.store6.core/StoreKey, org.mobilenativefoundation.store6.core/StoreError, kotlin.time/Duration) // org.mobilenativefoundation.store6.core.seam/StoreTelemetry.onFetchFailed|onFetchFailed(org.mobilenativefoundation.store6.core.StoreKey;org.mobilenativefoundation.store6.core.StoreError;kotlin.time.Duration){}[0] + open fun onFetchStarted(org.mobilenativefoundation.store6.core/StoreKey) // org.mobilenativefoundation.store6.core.seam/StoreTelemetry.onFetchStarted|onFetchStarted(org.mobilenativefoundation.store6.core.StoreKey){}[0] + open fun onFetchSucceeded(org.mobilenativefoundation.store6.core/StoreKey, kotlin.time/Duration) // org.mobilenativefoundation.store6.core.seam/StoreTelemetry.onFetchSucceeded|onFetchSucceeded(org.mobilenativefoundation.store6.core.StoreKey;kotlin.time.Duration){}[0] + open fun onInvalidated(org.mobilenativefoundation.store6.core/StoreKey) // org.mobilenativefoundation.store6.core.seam/StoreTelemetry.onInvalidated|onInvalidated(org.mobilenativefoundation.store6.core.StoreKey){}[0] + open fun onServe(org.mobilenativefoundation.store6.core/StoreKey, org.mobilenativefoundation.store6.core/Origin) // org.mobilenativefoundation.store6.core.seam/StoreTelemetry.onServe|onServe(org.mobilenativefoundation.store6.core.StoreKey;org.mobilenativefoundation.store6.core.Origin){}[0] +} + +abstract interface org.mobilenativefoundation.store6.core.seam/WallClock { // org.mobilenativefoundation.store6.core.seam/WallClock|null[0] + abstract fun nowEpochMillis(): kotlin/Long // org.mobilenativefoundation.store6.core.seam/WallClock.nowEpochMillis|nowEpochMillis(){}[0] +} + +abstract interface org.mobilenativefoundation.store6.core/StoreKey { // org.mobilenativefoundation.store6.core/StoreKey|null[0] + abstract val namespace // org.mobilenativefoundation.store6.core/StoreKey.namespace|{}namespace[0] + abstract fun (): org.mobilenativefoundation.store6.core/StoreNamespace // org.mobilenativefoundation.store6.core/StoreKey.namespace.|(){}[0] + + abstract fun canonicalId(): kotlin/String // org.mobilenativefoundation.store6.core/StoreKey.canonicalId|canonicalId(){}[0] +} + +abstract interface org.mobilenativefoundation.store6.core/StoreMeta { // org.mobilenativefoundation.store6.core/StoreMeta|null[0] + abstract val etag // org.mobilenativefoundation.store6.core/StoreMeta.etag|{}etag[0] + abstract fun (): kotlin/String? // org.mobilenativefoundation.store6.core/StoreMeta.etag.|(){}[0] + abstract val writtenAtEpochMillis // org.mobilenativefoundation.store6.core/StoreMeta.writtenAtEpochMillis|{}writtenAtEpochMillis[0] + abstract fun (): kotlin/Long // org.mobilenativefoundation.store6.core/StoreMeta.writtenAtEpochMillis.|(){}[0] +} + +sealed interface <#A: out kotlin/Any> org.mobilenativefoundation.store6.core.seam/FetcherResult { // org.mobilenativefoundation.store6.core.seam/FetcherResult|null[0] + final class <#A1: kotlin/Any> Success : org.mobilenativefoundation.store6.core.seam/FetcherResult<#A1> { // org.mobilenativefoundation.store6.core.seam/FetcherResult.Success|null[0] + constructor (#A1, kotlin/String? = ...) // org.mobilenativefoundation.store6.core.seam/FetcherResult.Success.|(1:0;kotlin.String?){}[0] + + final val etag // org.mobilenativefoundation.store6.core.seam/FetcherResult.Success.etag|{}etag[0] + final fun (): kotlin/String? // org.mobilenativefoundation.store6.core.seam/FetcherResult.Success.etag.|(){}[0] + final val value // org.mobilenativefoundation.store6.core.seam/FetcherResult.Success.value|{}value[0] + final fun (): #A1 // org.mobilenativefoundation.store6.core.seam/FetcherResult.Success.value.|(){}[0] + } + + final class Error : org.mobilenativefoundation.store6.core.seam/FetcherResult { // org.mobilenativefoundation.store6.core.seam/FetcherResult.Error|null[0] + constructor (kotlin/Throwable) // org.mobilenativefoundation.store6.core.seam/FetcherResult.Error.|(kotlin.Throwable){}[0] + + final val cause // org.mobilenativefoundation.store6.core.seam/FetcherResult.Error.cause|{}cause[0] + final fun (): kotlin/Throwable // org.mobilenativefoundation.store6.core.seam/FetcherResult.Error.cause.|(){}[0] + } + + final class NotModified : org.mobilenativefoundation.store6.core.seam/FetcherResult { // org.mobilenativefoundation.store6.core.seam/FetcherResult.NotModified|null[0] + constructor (kotlin/String? = ...) // org.mobilenativefoundation.store6.core.seam/FetcherResult.NotModified.|(kotlin.String?){}[0] + + final val etag // org.mobilenativefoundation.store6.core.seam/FetcherResult.NotModified.etag|{}etag[0] + final fun (): kotlin/String? // org.mobilenativefoundation.store6.core.seam/FetcherResult.NotModified.etag.|(){}[0] + } + + final object Deleted : org.mobilenativefoundation.store6.core.seam/FetcherResult { // org.mobilenativefoundation.store6.core.seam/FetcherResult.Deleted|null[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.mobilenativefoundation.store6.core.seam/FetcherResult.Deleted.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.mobilenativefoundation.store6.core.seam/FetcherResult.Deleted.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.mobilenativefoundation.store6.core.seam/FetcherResult.Deleted.toString|toString(){}[0] + } +} + +sealed interface <#A: out kotlin/Any?> org.mobilenativefoundation.store6.core/StoreResult { // org.mobilenativefoundation.store6.core/StoreResult|null[0] + final class <#A1: kotlin/Any?> Data : org.mobilenativefoundation.store6.core/StoreResult<#A1> { // org.mobilenativefoundation.store6.core/StoreResult.Data|null[0] + final val age // org.mobilenativefoundation.store6.core/StoreResult.Data.age|{}age[0] + final fun (): kotlin.time/Duration // org.mobilenativefoundation.store6.core/StoreResult.Data.age.|(){}[0] + final val isStale // org.mobilenativefoundation.store6.core/StoreResult.Data.isStale|{}isStale[0] + final fun (): kotlin/Boolean // org.mobilenativefoundation.store6.core/StoreResult.Data.isStale.|(){}[0] + final val origin // org.mobilenativefoundation.store6.core/StoreResult.Data.origin|{}origin[0] + final fun (): org.mobilenativefoundation.store6.core/Origin // org.mobilenativefoundation.store6.core/StoreResult.Data.origin.|(){}[0] + final val refreshing // org.mobilenativefoundation.store6.core/StoreResult.Data.refreshing|{}refreshing[0] + final fun (): kotlin/Boolean // org.mobilenativefoundation.store6.core/StoreResult.Data.refreshing.|(){}[0] + final val value // org.mobilenativefoundation.store6.core/StoreResult.Data.value|{}value[0] + final fun (): #A1 // org.mobilenativefoundation.store6.core/StoreResult.Data.value.|(){}[0] + } + + final class Error : org.mobilenativefoundation.store6.core/StoreResult { // org.mobilenativefoundation.store6.core/StoreResult.Error|null[0] + final val error // org.mobilenativefoundation.store6.core/StoreResult.Error.error|{}error[0] + final fun (): org.mobilenativefoundation.store6.core/StoreError // org.mobilenativefoundation.store6.core/StoreResult.Error.error.|(){}[0] + final val servedStale // org.mobilenativefoundation.store6.core/StoreResult.Error.servedStale|{}servedStale[0] + final fun (): kotlin/Boolean // org.mobilenativefoundation.store6.core/StoreResult.Error.servedStale.|(){}[0] + } + + final class Loading : org.mobilenativefoundation.store6.core/StoreResult // org.mobilenativefoundation.store6.core/StoreResult.Loading|null[0] + + final class Revalidated : org.mobilenativefoundation.store6.core/StoreResult { // org.mobilenativefoundation.store6.core/StoreResult.Revalidated|null[0] + final val age // org.mobilenativefoundation.store6.core/StoreResult.Revalidated.age|{}age[0] + final fun (): kotlin.time/Duration // org.mobilenativefoundation.store6.core/StoreResult.Revalidated.age.|(){}[0] + } +} + +sealed interface org.mobilenativefoundation.store6.core.seam/FetchPlan { // org.mobilenativefoundation.store6.core.seam/FetchPlan|null[0] + final class Conditional : org.mobilenativefoundation.store6.core.seam/FetchPlan { // org.mobilenativefoundation.store6.core.seam/FetchPlan.Conditional|null[0] + constructor (kotlin/String, kotlin/Boolean) // org.mobilenativefoundation.store6.core.seam/FetchPlan.Conditional.|(kotlin.String;kotlin.Boolean){}[0] + + final val etag // org.mobilenativefoundation.store6.core.seam/FetchPlan.Conditional.etag|{}etag[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.core.seam/FetchPlan.Conditional.etag.|(){}[0] + final val servesResidentWhileFetching // org.mobilenativefoundation.store6.core.seam/FetchPlan.Conditional.servesResidentWhileFetching|{}servesResidentWhileFetching[0] + final fun (): kotlin/Boolean // org.mobilenativefoundation.store6.core.seam/FetchPlan.Conditional.servesResidentWhileFetching.|(){}[0] + } + + final class Fetch : org.mobilenativefoundation.store6.core.seam/FetchPlan { // org.mobilenativefoundation.store6.core.seam/FetchPlan.Fetch|null[0] + constructor (kotlin/Boolean) // org.mobilenativefoundation.store6.core.seam/FetchPlan.Fetch.|(kotlin.Boolean){}[0] + + final val servesResidentWhileFetching // org.mobilenativefoundation.store6.core.seam/FetchPlan.Fetch.servesResidentWhileFetching|{}servesResidentWhileFetching[0] + final fun (): kotlin/Boolean // org.mobilenativefoundation.store6.core.seam/FetchPlan.Fetch.servesResidentWhileFetching.|(){}[0] + } + + final object Skip : org.mobilenativefoundation.store6.core.seam/FetchPlan { // org.mobilenativefoundation.store6.core.seam/FetchPlan.Skip|null[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.mobilenativefoundation.store6.core.seam/FetchPlan.Skip.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.mobilenativefoundation.store6.core.seam/FetchPlan.Skip.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.mobilenativefoundation.store6.core.seam/FetchPlan.Skip.toString|toString(){}[0] + } +} + +sealed interface org.mobilenativefoundation.store6.core/Freshness { // org.mobilenativefoundation.store6.core/Freshness|null[0] + final class MaxAge : org.mobilenativefoundation.store6.core/Freshness { // org.mobilenativefoundation.store6.core/Freshness.MaxAge|null[0] + constructor (kotlin.time/Duration) // org.mobilenativefoundation.store6.core/Freshness.MaxAge.|(kotlin.time.Duration){}[0] + + final val notOlderThan // org.mobilenativefoundation.store6.core/Freshness.MaxAge.notOlderThan|{}notOlderThan[0] + final fun (): kotlin.time/Duration // org.mobilenativefoundation.store6.core/Freshness.MaxAge.notOlderThan.|(){}[0] + } + + final object CachedOrFetch : org.mobilenativefoundation.store6.core/Freshness { // org.mobilenativefoundation.store6.core/Freshness.CachedOrFetch|null[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.mobilenativefoundation.store6.core/Freshness.CachedOrFetch.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.mobilenativefoundation.store6.core/Freshness.CachedOrFetch.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.mobilenativefoundation.store6.core/Freshness.CachedOrFetch.toString|toString(){}[0] + } + + final object LocalOnly : org.mobilenativefoundation.store6.core/Freshness { // org.mobilenativefoundation.store6.core/Freshness.LocalOnly|null[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.mobilenativefoundation.store6.core/Freshness.LocalOnly.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.mobilenativefoundation.store6.core/Freshness.LocalOnly.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.mobilenativefoundation.store6.core/Freshness.LocalOnly.toString|toString(){}[0] + } + + final object MustBeFresh : org.mobilenativefoundation.store6.core/Freshness { // org.mobilenativefoundation.store6.core/Freshness.MustBeFresh|null[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.mobilenativefoundation.store6.core/Freshness.MustBeFresh.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.mobilenativefoundation.store6.core/Freshness.MustBeFresh.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.mobilenativefoundation.store6.core/Freshness.MustBeFresh.toString|toString(){}[0] + } + + final object StaleIfError : org.mobilenativefoundation.store6.core/Freshness { // org.mobilenativefoundation.store6.core/Freshness.StaleIfError|null[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.mobilenativefoundation.store6.core/Freshness.StaleIfError.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.mobilenativefoundation.store6.core/Freshness.StaleIfError.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.mobilenativefoundation.store6.core/Freshness.StaleIfError.toString|toString(){}[0] + } +} + +abstract class org.mobilenativefoundation.store6.core.seam/KeyEvents { // org.mobilenativefoundation.store6.core.seam/KeyEvents|null[0] + abstract val key // org.mobilenativefoundation.store6.core.seam/KeyEvents.key|{}key[0] + abstract fun (): org.mobilenativefoundation.store6.core/StoreKey // org.mobilenativefoundation.store6.core.seam/KeyEvents.key.|(){}[0] + + final class Deleted : org.mobilenativefoundation.store6.core.seam/KeyEvents { // org.mobilenativefoundation.store6.core.seam/KeyEvents.Deleted|null[0] + final val key // org.mobilenativefoundation.store6.core.seam/KeyEvents.Deleted.key|{}key[0] + final fun (): org.mobilenativefoundation.store6.core/StoreKey // org.mobilenativefoundation.store6.core.seam/KeyEvents.Deleted.key.|(){}[0] + } + + final class Invalidated : org.mobilenativefoundation.store6.core.seam/KeyEvents { // org.mobilenativefoundation.store6.core.seam/KeyEvents.Invalidated|null[0] + final val key // org.mobilenativefoundation.store6.core.seam/KeyEvents.Invalidated.key|{}key[0] + final fun (): org.mobilenativefoundation.store6.core/StoreKey // org.mobilenativefoundation.store6.core.seam/KeyEvents.Invalidated.key.|(){}[0] + } + + final class Written : org.mobilenativefoundation.store6.core.seam/KeyEvents { // org.mobilenativefoundation.store6.core.seam/KeyEvents.Written|null[0] + final val key // org.mobilenativefoundation.store6.core.seam/KeyEvents.Written.key|{}key[0] + final fun (): org.mobilenativefoundation.store6.core/StoreKey // org.mobilenativefoundation.store6.core.seam/KeyEvents.Written.key.|(){}[0] + final val origin // org.mobilenativefoundation.store6.core.seam/KeyEvents.Written.origin|{}origin[0] + final fun (): org.mobilenativefoundation.store6.core/Origin // org.mobilenativefoundation.store6.core.seam/KeyEvents.Written.origin.|(){}[0] + } +} + +final class <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> org.mobilenativefoundation.store6.core/StoreBuilder { // org.mobilenativefoundation.store6.core/StoreBuilder|null[0] + final fun bookkeeper(org.mobilenativefoundation.store6.core.seam/Bookkeeper) // org.mobilenativefoundation.store6.core/StoreBuilder.bookkeeper|bookkeeper(org.mobilenativefoundation.store6.core.seam.Bookkeeper){}[0] + final fun fetcher(kotlin.coroutines/SuspendFunction1<#A, #B>) // org.mobilenativefoundation.store6.core/StoreBuilder.fetcher|fetcher(kotlin.coroutines.SuspendFunction1<1:0,1:1>){}[0] + final fun fetcher(org.mobilenativefoundation.store6.core.seam/Fetcher<#A, #B>) // org.mobilenativefoundation.store6.core/StoreBuilder.fetcher|fetcher(org.mobilenativefoundation.store6.core.seam.Fetcher<1:0,1:1>){}[0] + final fun fetcherOfResult(kotlin.coroutines/SuspendFunction1<#A, org.mobilenativefoundation.store6.core.seam/FetcherResult<#B>>) // org.mobilenativefoundation.store6.core/StoreBuilder.fetcherOfResult|fetcherOfResult(kotlin.coroutines.SuspendFunction1<1:0,org.mobilenativefoundation.store6.core.seam.FetcherResult<1:1>>){}[0] + final fun freshnessValidator(org.mobilenativefoundation.store6.core.seam/FreshnessValidator) // org.mobilenativefoundation.store6.core/StoreBuilder.freshnessValidator|freshnessValidator(org.mobilenativefoundation.store6.core.seam.FreshnessValidator){}[0] + final fun maxIdleKeys(kotlin/Int) // org.mobilenativefoundation.store6.core/StoreBuilder.maxIdleKeys|maxIdleKeys(kotlin.Int){}[0] + final fun overlay(org.mobilenativefoundation.store6.core.seam/Overlay<#A, #B>) // org.mobilenativefoundation.store6.core/StoreBuilder.overlay|overlay(org.mobilenativefoundation.store6.core.seam.Overlay<1:0,1:1>){}[0] + final fun persistence(org.mobilenativefoundation.store6.core.seam/SourceOfTruth<#A, #B>) // org.mobilenativefoundation.store6.core/StoreBuilder.persistence|persistence(org.mobilenativefoundation.store6.core.seam.SourceOfTruth<1:0,1:1>){}[0] + final fun telemetry(org.mobilenativefoundation.store6.core.seam/StoreTelemetry) // org.mobilenativefoundation.store6.core/StoreBuilder.telemetry|telemetry(org.mobilenativefoundation.store6.core.seam.StoreTelemetry){}[0] + final fun wallClock(org.mobilenativefoundation.store6.core.seam/WallClock) // org.mobilenativefoundation.store6.core/StoreBuilder.wallClock|wallClock(org.mobilenativefoundation.store6.core.seam.WallClock){}[0] +} + +final class org.mobilenativefoundation.store6.core.seam/FreshnessContext { // org.mobilenativefoundation.store6.core.seam/FreshnessContext|null[0] + constructor (kotlin/Boolean, org.mobilenativefoundation.store6.core/StoreMeta?, kotlin/Boolean, org.mobilenativefoundation.store6.core/Freshness, kotlin/Long, org.mobilenativefoundation.store6.core.seam/KeyStatus? = ...) // org.mobilenativefoundation.store6.core.seam/FreshnessContext.|(kotlin.Boolean;org.mobilenativefoundation.store6.core.StoreMeta?;kotlin.Boolean;org.mobilenativefoundation.store6.core.Freshness;kotlin.Long;org.mobilenativefoundation.store6.core.seam.KeyStatus?){}[0] + + final val epochStale // org.mobilenativefoundation.store6.core.seam/FreshnessContext.epochStale|{}epochStale[0] + final fun (): kotlin/Boolean // org.mobilenativefoundation.store6.core.seam/FreshnessContext.epochStale.|(){}[0] + final val freshness // org.mobilenativefoundation.store6.core.seam/FreshnessContext.freshness|{}freshness[0] + final fun (): org.mobilenativefoundation.store6.core/Freshness // org.mobilenativefoundation.store6.core.seam/FreshnessContext.freshness.|(){}[0] + final val hasResidentValue // org.mobilenativefoundation.store6.core.seam/FreshnessContext.hasResidentValue|{}hasResidentValue[0] + final fun (): kotlin/Boolean // org.mobilenativefoundation.store6.core.seam/FreshnessContext.hasResidentValue.|(){}[0] + final val meta // org.mobilenativefoundation.store6.core.seam/FreshnessContext.meta|{}meta[0] + final fun (): org.mobilenativefoundation.store6.core/StoreMeta? // org.mobilenativefoundation.store6.core.seam/FreshnessContext.meta.|(){}[0] + final val nowEpochMillis // org.mobilenativefoundation.store6.core.seam/FreshnessContext.nowEpochMillis|{}nowEpochMillis[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.core.seam/FreshnessContext.nowEpochMillis.|(){}[0] + final val status // org.mobilenativefoundation.store6.core.seam/FreshnessContext.status|{}status[0] + final fun (): org.mobilenativefoundation.store6.core.seam/KeyStatus? // org.mobilenativefoundation.store6.core.seam/FreshnessContext.status.|(){}[0] +} + +final class org.mobilenativefoundation.store6.core.seam/KeyStatus { // org.mobilenativefoundation.store6.core.seam/KeyStatus|null[0] + constructor (org.mobilenativefoundation.store6.core/StoreMeta?, kotlin/Long?, kotlin/Long?, kotlin/Int, kotlin/Boolean) // org.mobilenativefoundation.store6.core.seam/KeyStatus.|(org.mobilenativefoundation.store6.core.StoreMeta?;kotlin.Long?;kotlin.Long?;kotlin.Int;kotlin.Boolean){}[0] + + final val consecutiveFailures // org.mobilenativefoundation.store6.core.seam/KeyStatus.consecutiveFailures|{}consecutiveFailures[0] + final fun (): kotlin/Int // org.mobilenativefoundation.store6.core.seam/KeyStatus.consecutiveFailures.|(){}[0] + final val durablyStale // org.mobilenativefoundation.store6.core.seam/KeyStatus.durablyStale|{}durablyStale[0] + final fun (): kotlin/Boolean // org.mobilenativefoundation.store6.core.seam/KeyStatus.durablyStale.|(){}[0] + final val lastFailureAtEpochMillis // org.mobilenativefoundation.store6.core.seam/KeyStatus.lastFailureAtEpochMillis|{}lastFailureAtEpochMillis[0] + final fun (): kotlin/Long? // org.mobilenativefoundation.store6.core.seam/KeyStatus.lastFailureAtEpochMillis.|(){}[0] + final val lastSuccessSequence // org.mobilenativefoundation.store6.core.seam/KeyStatus.lastSuccessSequence|{}lastSuccessSequence[0] + final fun (): kotlin/Long? // org.mobilenativefoundation.store6.core.seam/KeyStatus.lastSuccessSequence.|(){}[0] + final val meta // org.mobilenativefoundation.store6.core.seam/KeyStatus.meta|{}meta[0] + final fun (): org.mobilenativefoundation.store6.core/StoreMeta? // org.mobilenativefoundation.store6.core.seam/KeyStatus.meta.|(){}[0] +} + +final class org.mobilenativefoundation.store6.core/StoreException : kotlin/RuntimeException { // org.mobilenativefoundation.store6.core/StoreException|null[0] + final val error // org.mobilenativefoundation.store6.core/StoreException.error|{}error[0] + final fun (): org.mobilenativefoundation.store6.core/StoreError // org.mobilenativefoundation.store6.core/StoreException.error.|(){}[0] +} + +final class org.mobilenativefoundation.store6.core/StoreNamespace { // org.mobilenativefoundation.store6.core/StoreNamespace|null[0] + constructor (kotlin/String) // org.mobilenativefoundation.store6.core/StoreNamespace.|(kotlin.String){}[0] + + final val value // org.mobilenativefoundation.store6.core/StoreNamespace.value|{}value[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.core/StoreNamespace.value.|(){}[0] +} + +sealed class org.mobilenativefoundation.store6.core/StoreError { // org.mobilenativefoundation.store6.core/StoreError|null[0] + final class Conflict : org.mobilenativefoundation.store6.core/StoreError { // org.mobilenativefoundation.store6.core/StoreError.Conflict|null[0] + final val message // org.mobilenativefoundation.store6.core/StoreError.Conflict.message|{}message[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.core/StoreError.Conflict.message.|(){}[0] + final val serverMeta // org.mobilenativefoundation.store6.core/StoreError.Conflict.serverMeta|{}serverMeta[0] + final fun (): org.mobilenativefoundation.store6.core/StoreMeta? // org.mobilenativefoundation.store6.core/StoreError.Conflict.serverMeta.|(){}[0] + } + + final class Conversion : org.mobilenativefoundation.store6.core/StoreError { // org.mobilenativefoundation.store6.core/StoreError.Conversion|null[0] + final val cause // org.mobilenativefoundation.store6.core/StoreError.Conversion.cause|{}cause[0] + final fun (): kotlin/Throwable? // org.mobilenativefoundation.store6.core/StoreError.Conversion.cause.|(){}[0] + final val message // org.mobilenativefoundation.store6.core/StoreError.Conversion.message|{}message[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.core/StoreError.Conversion.message.|(){}[0] + } + + final class Fetch : org.mobilenativefoundation.store6.core/StoreError { // org.mobilenativefoundation.store6.core/StoreError.Fetch|null[0] + final val cause // org.mobilenativefoundation.store6.core/StoreError.Fetch.cause|{}cause[0] + final fun (): kotlin/Throwable? // org.mobilenativefoundation.store6.core/StoreError.Fetch.cause.|(){}[0] + final val message // org.mobilenativefoundation.store6.core/StoreError.Fetch.message|{}message[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.core/StoreError.Fetch.message.|(){}[0] + } + + final class FreshnessUnsatisfiable : org.mobilenativefoundation.store6.core/StoreError { // org.mobilenativefoundation.store6.core/StoreError.FreshnessUnsatisfiable|null[0] + final val message // org.mobilenativefoundation.store6.core/StoreError.FreshnessUnsatisfiable.message|{}message[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.core/StoreError.FreshnessUnsatisfiable.message.|(){}[0] + } + + final class Missing : org.mobilenativefoundation.store6.core/StoreError { // org.mobilenativefoundation.store6.core/StoreError.Missing|null[0] + final val key // org.mobilenativefoundation.store6.core/StoreError.Missing.key|{}key[0] + final fun (): org.mobilenativefoundation.store6.core/StoreKey // org.mobilenativefoundation.store6.core/StoreError.Missing.key.|(){}[0] + final val message // org.mobilenativefoundation.store6.core/StoreError.Missing.message|{}message[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.core/StoreError.Missing.message.|(){}[0] + } + + final class Persistence : org.mobilenativefoundation.store6.core/StoreError { // org.mobilenativefoundation.store6.core/StoreError.Persistence|null[0] + final val cause // org.mobilenativefoundation.store6.core/StoreError.Persistence.cause|{}cause[0] + final fun (): kotlin/Throwable? // org.mobilenativefoundation.store6.core/StoreError.Persistence.cause.|(){}[0] + final val message // org.mobilenativefoundation.store6.core/StoreError.Persistence.message|{}message[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.core/StoreError.Persistence.message.|(){}[0] + } +} + +final object org.mobilenativefoundation.store6.core.seam/StoreResults { // org.mobilenativefoundation.store6.core.seam/StoreResults|null[0] + final fun <#A1: kotlin/Any?> data(#A1, org.mobilenativefoundation.store6.core/Origin, kotlin.time/Duration, kotlin/Boolean, kotlin/Boolean): org.mobilenativefoundation.store6.core/StoreResult.Data<#A1> // org.mobilenativefoundation.store6.core.seam/StoreResults.data|data(0:0;org.mobilenativefoundation.store6.core.Origin;kotlin.time.Duration;kotlin.Boolean;kotlin.Boolean){0§}[0] + final fun conflict(org.mobilenativefoundation.store6.core/StoreMeta?, kotlin/String): org.mobilenativefoundation.store6.core/StoreError.Conflict // org.mobilenativefoundation.store6.core.seam/StoreResults.conflict|conflict(org.mobilenativefoundation.store6.core.StoreMeta?;kotlin.String){}[0] + final fun conversionError(kotlin/String, kotlin/Throwable? = ...): org.mobilenativefoundation.store6.core/StoreError.Conversion // org.mobilenativefoundation.store6.core.seam/StoreResults.conversionError|conversionError(kotlin.String;kotlin.Throwable?){}[0] + final fun error(org.mobilenativefoundation.store6.core/StoreError, kotlin/Boolean): org.mobilenativefoundation.store6.core/StoreResult.Error // org.mobilenativefoundation.store6.core.seam/StoreResults.error|error(org.mobilenativefoundation.store6.core.StoreError;kotlin.Boolean){}[0] + final fun exception(org.mobilenativefoundation.store6.core/StoreError, kotlin/Throwable? = ...): org.mobilenativefoundation.store6.core/StoreException // org.mobilenativefoundation.store6.core.seam/StoreResults.exception|exception(org.mobilenativefoundation.store6.core.StoreError;kotlin.Throwable?){}[0] + final fun fetchError(kotlin/String, kotlin/Throwable? = ...): org.mobilenativefoundation.store6.core/StoreError.Fetch // org.mobilenativefoundation.store6.core.seam/StoreResults.fetchError|fetchError(kotlin.String;kotlin.Throwable?){}[0] + final fun freshnessUnsatisfiable(kotlin/String): org.mobilenativefoundation.store6.core/StoreError.FreshnessUnsatisfiable // org.mobilenativefoundation.store6.core.seam/StoreResults.freshnessUnsatisfiable|freshnessUnsatisfiable(kotlin.String){}[0] + final fun loading(): org.mobilenativefoundation.store6.core/StoreResult.Loading // org.mobilenativefoundation.store6.core.seam/StoreResults.loading|loading(){}[0] + final fun missing(org.mobilenativefoundation.store6.core/StoreKey, kotlin/String): org.mobilenativefoundation.store6.core/StoreError.Missing // org.mobilenativefoundation.store6.core.seam/StoreResults.missing|missing(org.mobilenativefoundation.store6.core.StoreKey;kotlin.String){}[0] + final fun persistenceError(kotlin/String, kotlin/Throwable? = ...): org.mobilenativefoundation.store6.core/StoreError.Persistence // org.mobilenativefoundation.store6.core.seam/StoreResults.persistenceError|persistenceError(kotlin.String;kotlin.Throwable?){}[0] + final fun revalidated(kotlin.time/Duration): org.mobilenativefoundation.store6.core/StoreResult.Revalidated // org.mobilenativefoundation.store6.core.seam/StoreResults.revalidated|revalidated(kotlin.time.Duration){}[0] +} + +final fun <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> (org.mobilenativefoundation.store6.core/Store<#A, #B>).org.mobilenativefoundation.store6.core.seam/runtime(): org.mobilenativefoundation.store6.core.seam/StoreRuntime<#A, #B>? // org.mobilenativefoundation.store6.core.seam/runtime|runtime@org.mobilenativefoundation.store6.core.Store<0:0,0:1>(){0§;1§}[0] +final fun <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> org.mobilenativefoundation.store6.core/store(kotlin/Function1, kotlin/Unit>): org.mobilenativefoundation.store6.core/Store<#A, #B> // org.mobilenativefoundation.store6.core/store|store(kotlin.Function1,kotlin.Unit>){0§;1§}[0] diff --git a/store6-core/api/swift/objc/Store6Core.h b/store6-core/api/swift/objc/Store6Core.h new file mode 100644 index 000000000..39dfa6d32 --- /dev/null +++ b/store6-core/api/swift/objc/Store6Core.h @@ -0,0 +1,1005 @@ +#import +#import +#import +#import +#import +#import +#import + +@class Store6CoreFetchPlanSkip, Store6CoreFetcherResultDeleted, Store6CoreFreshnessCachedOrFetch, Store6CoreFreshnessContext, Store6CoreFreshnessLocalOnly, Store6CoreFreshnessMustBeFresh, Store6CoreFreshnessStaleIfError, Store6CoreKeyEvents, Store6CoreKeyStatus, Store6CoreKotlinArray, Store6CoreKotlinEnum, Store6CoreKotlinEnumCompanion, Store6CoreKotlinException, Store6CoreKotlinIllegalStateException, Store6CoreKotlinRuntimeException, Store6CoreKotlinThrowable, Store6CoreOrigin, Store6CoreStoreBuilder, Store6CoreStoreError, Store6CoreStoreErrorConflict, Store6CoreStoreErrorConversion, Store6CoreStoreErrorFetch, Store6CoreStoreErrorFreshnessUnsatisfiable, Store6CoreStoreErrorMissing, Store6CoreStoreErrorPersistence, Store6CoreStoreException, Store6CoreStoreNamespace, Store6CoreStoreResultData, Store6CoreStoreResultError, Store6CoreStoreResultLoading, Store6CoreStoreResultRevalidated, Store6CoreStoreResults; + +@protocol Store6CoreBookkeeper, Store6CoreFetchPlan, Store6CoreFetcher, Store6CoreFetcherResult, Store6CoreFreshness, Store6CoreFreshnessValidator, Store6CoreKotlinComparable, Store6CoreKotlinFunction, Store6CoreKotlinIterator, Store6CoreKotlinSuspendFunction0, Store6CoreKotlinSuspendFunction1, Store6CoreKotlinx_coroutines_coreFlow, Store6CoreKotlinx_coroutines_coreFlowCollector, Store6CoreOverlay, Store6CoreSourceOfTruth, Store6CoreStore, Store6CoreStoreKey, Store6CoreStoreMeta, Store6CoreStoreResult, Store6CoreStoreRuntime, Store6CoreStoreTelemetry, Store6CoreStoreWriteHandle, Store6CoreWallClock; + +NS_ASSUME_NONNULL_BEGIN +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wunknown-warning-option" +#pragma clang diagnostic ignored "-Wincompatible-property-type" +#pragma clang diagnostic ignored "-Wnullability" + +#pragma push_macro("_Nullable_result") +#if !__has_feature(nullability_nullable_result) +#undef _Nullable_result +#define _Nullable_result _Nullable +#endif + +__attribute__((swift_name("KotlinBase"))) +@interface Store6CoreBase : NSObject +- (instancetype)init __attribute__((unavailable)); ++ (instancetype)new __attribute__((unavailable)); ++ (void)initialize __attribute__((objc_requires_super)); +@end + +@interface Store6CoreBase (Store6CoreBaseCopying) +@end + +__attribute__((swift_name("KotlinMutableSet"))) +@interface Store6CoreMutableSet : NSMutableSet +@end + +__attribute__((swift_name("KotlinMutableDictionary"))) +@interface Store6CoreMutableDictionary : NSMutableDictionary +@end + +@interface NSError (NSErrorStore6CoreKotlinException) +@property (readonly) id _Nullable kotlinException; +@end + +__attribute__((swift_name("KotlinNumber"))) +@interface Store6CoreNumber : NSNumber +- (instancetype)initWithChar:(char)value __attribute__((unavailable)); +- (instancetype)initWithUnsignedChar:(unsigned char)value __attribute__((unavailable)); +- (instancetype)initWithShort:(short)value __attribute__((unavailable)); +- (instancetype)initWithUnsignedShort:(unsigned short)value __attribute__((unavailable)); +- (instancetype)initWithInt:(int)value __attribute__((unavailable)); +- (instancetype)initWithUnsignedInt:(unsigned int)value __attribute__((unavailable)); +- (instancetype)initWithLong:(long)value __attribute__((unavailable)); +- (instancetype)initWithUnsignedLong:(unsigned long)value __attribute__((unavailable)); +- (instancetype)initWithLongLong:(long long)value __attribute__((unavailable)); +- (instancetype)initWithUnsignedLongLong:(unsigned long long)value __attribute__((unavailable)); +- (instancetype)initWithFloat:(float)value __attribute__((unavailable)); +- (instancetype)initWithDouble:(double)value __attribute__((unavailable)); +- (instancetype)initWithBool:(BOOL)value __attribute__((unavailable)); +- (instancetype)initWithInteger:(NSInteger)value __attribute__((unavailable)); +- (instancetype)initWithUnsignedInteger:(NSUInteger)value __attribute__((unavailable)); ++ (instancetype)numberWithChar:(char)value __attribute__((unavailable)); ++ (instancetype)numberWithUnsignedChar:(unsigned char)value __attribute__((unavailable)); ++ (instancetype)numberWithShort:(short)value __attribute__((unavailable)); ++ (instancetype)numberWithUnsignedShort:(unsigned short)value __attribute__((unavailable)); ++ (instancetype)numberWithInt:(int)value __attribute__((unavailable)); ++ (instancetype)numberWithUnsignedInt:(unsigned int)value __attribute__((unavailable)); ++ (instancetype)numberWithLong:(long)value __attribute__((unavailable)); ++ (instancetype)numberWithUnsignedLong:(unsigned long)value __attribute__((unavailable)); ++ (instancetype)numberWithLongLong:(long long)value __attribute__((unavailable)); ++ (instancetype)numberWithUnsignedLongLong:(unsigned long long)value __attribute__((unavailable)); ++ (instancetype)numberWithFloat:(float)value __attribute__((unavailable)); ++ (instancetype)numberWithDouble:(double)value __attribute__((unavailable)); ++ (instancetype)numberWithBool:(BOOL)value __attribute__((unavailable)); ++ (instancetype)numberWithInteger:(NSInteger)value __attribute__((unavailable)); ++ (instancetype)numberWithUnsignedInteger:(NSUInteger)value __attribute__((unavailable)); +@end + +__attribute__((swift_name("KotlinByte"))) +@interface Store6CoreByte : Store6CoreNumber +- (instancetype)initWithChar:(char)value; ++ (instancetype)numberWithChar:(char)value; +@end + +__attribute__((swift_name("KotlinUByte"))) +@interface Store6CoreUByte : Store6CoreNumber +- (instancetype)initWithUnsignedChar:(unsigned char)value; ++ (instancetype)numberWithUnsignedChar:(unsigned char)value; +@end + +__attribute__((swift_name("KotlinShort"))) +@interface Store6CoreShort : Store6CoreNumber +- (instancetype)initWithShort:(short)value; ++ (instancetype)numberWithShort:(short)value; +@end + +__attribute__((swift_name("KotlinUShort"))) +@interface Store6CoreUShort : Store6CoreNumber +- (instancetype)initWithUnsignedShort:(unsigned short)value; ++ (instancetype)numberWithUnsignedShort:(unsigned short)value; +@end + +__attribute__((swift_name("KotlinInt"))) +@interface Store6CoreInt : Store6CoreNumber +- (instancetype)initWithInt:(int)value; ++ (instancetype)numberWithInt:(int)value; +@end + +__attribute__((swift_name("KotlinUInt"))) +@interface Store6CoreUInt : Store6CoreNumber +- (instancetype)initWithUnsignedInt:(unsigned int)value; ++ (instancetype)numberWithUnsignedInt:(unsigned int)value; +@end + +__attribute__((swift_name("KotlinLong"))) +@interface Store6CoreLong : Store6CoreNumber +- (instancetype)initWithLongLong:(long long)value; ++ (instancetype)numberWithLongLong:(long long)value; +@end + +__attribute__((swift_name("KotlinULong"))) +@interface Store6CoreULong : Store6CoreNumber +- (instancetype)initWithUnsignedLongLong:(unsigned long long)value; ++ (instancetype)numberWithUnsignedLongLong:(unsigned long long)value; +@end + +__attribute__((swift_name("KotlinFloat"))) +@interface Store6CoreFloat : Store6CoreNumber +- (instancetype)initWithFloat:(float)value; ++ (instancetype)numberWithFloat:(float)value; +@end + +__attribute__((swift_name("KotlinDouble"))) +@interface Store6CoreDouble : Store6CoreNumber +- (instancetype)initWithDouble:(double)value; ++ (instancetype)numberWithDouble:(double)value; +@end + +__attribute__((swift_name("KotlinBoolean"))) +@interface Store6CoreBoolean : Store6CoreNumber +- (instancetype)initWithBool:(BOOL)value; ++ (instancetype)numberWithBool:(BOOL)value; +@end + +__attribute__((swift_name("Freshness"))) +@protocol Store6CoreFreshness +@required +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FreshnessCachedOrFetch"))) +@interface Store6CoreFreshnessCachedOrFetch : Store6CoreBase ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)cachedOrFetch __attribute__((swift_name("init()"))); +@property (class, readonly, getter=shared) Store6CoreFreshnessCachedOrFetch *shared __attribute__((swift_name("shared"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FreshnessLocalOnly"))) +@interface Store6CoreFreshnessLocalOnly : Store6CoreBase ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)localOnly __attribute__((swift_name("init()"))); +@property (class, readonly, getter=shared) Store6CoreFreshnessLocalOnly *shared __attribute__((swift_name("shared"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FreshnessMaxAge"))) +@interface Store6CoreFreshnessMaxAge : Store6CoreBase +- (instancetype)initWithNotOlderThan:(int64_t)notOlderThan __attribute__((swift_name("init(notOlderThan:)"))) __attribute__((objc_designated_initializer)); +@property (readonly) int64_t notOlderThan __attribute__((swift_name("notOlderThan"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FreshnessMustBeFresh"))) +@interface Store6CoreFreshnessMustBeFresh : Store6CoreBase ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)mustBeFresh __attribute__((swift_name("init()"))); +@property (class, readonly, getter=shared) Store6CoreFreshnessMustBeFresh *shared __attribute__((swift_name("shared"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FreshnessStaleIfError"))) +@interface Store6CoreFreshnessStaleIfError : Store6CoreBase ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)staleIfError __attribute__((swift_name("init()"))); +@property (class, readonly, getter=shared) Store6CoreFreshnessStaleIfError *shared __attribute__((swift_name("shared"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + +__attribute__((swift_name("KotlinComparable"))) +@protocol Store6CoreKotlinComparable +@required +- (int32_t)compareToOther:(id _Nullable)other __attribute__((swift_name("compareTo(other:)"))); +@end + +__attribute__((swift_name("KotlinEnum"))) +@interface Store6CoreKotlinEnum : Store6CoreBase +- (instancetype)initWithName:(NSString *)name ordinal:(int32_t)ordinal __attribute__((swift_name("init(name:ordinal:)"))) __attribute__((objc_designated_initializer)); +@property (class, readonly, getter=companion) Store6CoreKotlinEnumCompanion *companion __attribute__((swift_name("companion"))); +- (int32_t)compareToOther:(E)other __attribute__((swift_name("compareTo(other:)"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@property (readonly) NSString *name __attribute__((swift_name("name"))); +@property (readonly) int32_t ordinal __attribute__((swift_name("ordinal"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Origin"))) +@interface Store6CoreOrigin : Store6CoreKotlinEnum ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); +- (instancetype)initWithName:(NSString *)name ordinal:(int32_t)ordinal __attribute__((swift_name("init(name:ordinal:)"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable)); +@property (class, readonly) Store6CoreOrigin *memory __attribute__((swift_name("memory"))); +@property (class, readonly) Store6CoreOrigin *sot __attribute__((swift_name("sot"))); +@property (class, readonly) Store6CoreOrigin *fetcher __attribute__((swift_name("fetcher"))); +@property (class, readonly) Store6CoreOrigin *overlay __attribute__((swift_name("overlay"))); ++ (Store6CoreKotlinArray *)values __attribute__((swift_name("values()"))); +@property (class, readonly) NSArray *entries __attribute__((swift_name("entries"))); +@end + + +/** + * @note annotations + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("Store"))) +@protocol Store6CoreStore +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)clearKey:(id)key completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("clear(key:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)clearAllWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("clearAll(completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)clearNamespaceNamespace:(Store6CoreStoreNamespace *)namespace_ completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("clearNamespace(namespace:completionHandler:)"))); +- (void)close __attribute__((swift_name("close()"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)getKey:(id)key freshness:(id)freshness completionHandler:(void (^)(id _Nullable, NSError * _Nullable))completionHandler __attribute__((swift_name("get(key:freshness:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)invalidateKey:(id)key completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("invalidate(key:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)invalidateAllWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("invalidateAll(completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)invalidateNamespaceNamespace:(Store6CoreStoreNamespace *)namespace_ completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("invalidateNamespace(namespace:completionHandler:)"))); +- (id)streamKey:(id)key freshness:(id)freshness __attribute__((swift_name("stream(key:freshness:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreBuilder"))) +@interface Store6CoreStoreBuilder : Store6CoreBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)bookkeeperBookkeeper:(id)bookkeeper __attribute__((swift_name("bookkeeper(bookkeeper:)"))); +- (void)fetcherFetch:(id)fetch __attribute__((swift_name("fetcher(fetch:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)fetcherFetcher:(id)fetcher __attribute__((swift_name("fetcher(fetcher:)"))); +- (void)fetcherOfResultFetch:(id)fetch __attribute__((swift_name("fetcherOfResult(fetch:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)freshnessValidatorValidator:(id)validator __attribute__((swift_name("freshnessValidator(validator:)"))); +- (void)maxIdleKeysCount:(int32_t)count __attribute__((swift_name("maxIdleKeys(count:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)overlayOverlay:(id)overlay __attribute__((swift_name("overlay(overlay:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)persistenceSot:(id)sot __attribute__((swift_name("persistence(sot:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)telemetryTelemetry:(id)telemetry __attribute__((swift_name("telemetry(telemetry:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)wallClockWallClock:(id)wallClock __attribute__((swift_name("wallClock(wallClock:)"))); +@end + +__attribute__((swift_name("StoreError"))) +@interface Store6CoreStoreError : Store6CoreBase +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreError.Conflict"))) +@interface Store6CoreStoreErrorConflict : Store6CoreStoreError +@property (readonly) NSString *message __attribute__((swift_name("message"))); +@property (readonly) id _Nullable serverMeta __attribute__((swift_name("serverMeta"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreError.Conversion"))) +@interface Store6CoreStoreErrorConversion : Store6CoreStoreError +@property (readonly) Store6CoreKotlinThrowable * _Nullable cause __attribute__((swift_name("cause"))); +@property (readonly) NSString *message __attribute__((swift_name("message"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreError.Fetch"))) +@interface Store6CoreStoreErrorFetch : Store6CoreStoreError +@property (readonly) Store6CoreKotlinThrowable * _Nullable cause __attribute__((swift_name("cause"))); +@property (readonly) NSString *message __attribute__((swift_name("message"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreError.FreshnessUnsatisfiable"))) +@interface Store6CoreStoreErrorFreshnessUnsatisfiable : Store6CoreStoreError +@property (readonly) NSString *message __attribute__((swift_name("message"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreError.Missing"))) +@interface Store6CoreStoreErrorMissing : Store6CoreStoreError +@property (readonly) id key __attribute__((swift_name("key"))); +@property (readonly) NSString *message __attribute__((swift_name("message"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreError.Persistence"))) +@interface Store6CoreStoreErrorPersistence : Store6CoreStoreError +@property (readonly) Store6CoreKotlinThrowable * _Nullable cause __attribute__((swift_name("cause"))); +@property (readonly) NSString *message __attribute__((swift_name("message"))); +@end + +__attribute__((swift_name("KotlinThrowable"))) +@interface Store6CoreKotlinThrowable : Store6CoreBase +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); ++ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); +- (instancetype)initWithMessage:(NSString * _Nullable)message __attribute__((swift_name("init(message:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithCause:(Store6CoreKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithMessage:(NSString * _Nullable)message cause:(Store6CoreKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(message:cause:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note annotations + * kotlin.experimental.ExperimentalNativeApi +*/ +- (Store6CoreKotlinArray *)getStackTrace __attribute__((swift_name("getStackTrace()"))); +- (void)printStackTrace __attribute__((swift_name("printStackTrace()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@property (readonly) Store6CoreKotlinThrowable * _Nullable cause __attribute__((swift_name("cause"))); +@property (readonly) NSString * _Nullable message __attribute__((swift_name("message"))); +- (NSError *)asError __attribute__((swift_name("asError()"))); +@end + +__attribute__((swift_name("KotlinException"))) +@interface Store6CoreKotlinException : Store6CoreKotlinThrowable +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); ++ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); +- (instancetype)initWithMessage:(NSString * _Nullable)message __attribute__((swift_name("init(message:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithCause:(Store6CoreKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithMessage:(NSString * _Nullable)message cause:(Store6CoreKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(message:cause:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((swift_name("KotlinRuntimeException"))) +@interface Store6CoreKotlinRuntimeException : Store6CoreKotlinException +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); ++ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); +- (instancetype)initWithMessage:(NSString * _Nullable)message __attribute__((swift_name("init(message:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithCause:(Store6CoreKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithMessage:(NSString * _Nullable)message cause:(Store6CoreKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(message:cause:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreException"))) +@interface Store6CoreStoreException : Store6CoreKotlinRuntimeException +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable)); ++ (instancetype)new __attribute__((unavailable)); +- (instancetype)initWithMessage:(NSString * _Nullable)message __attribute__((swift_name("init(message:)"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable)); +- (instancetype)initWithCause:(Store6CoreKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable)); +- (instancetype)initWithMessage:(NSString * _Nullable)message cause:(Store6CoreKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(message:cause:)"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable)); +@property (readonly) Store6CoreStoreError *error __attribute__((swift_name("error"))); +@end + +__attribute__((swift_name("StoreKey"))) +@protocol Store6CoreStoreKey +@required +- (NSString *)canonicalId __attribute__((swift_name("canonicalId()"))); +@property (readonly, getter=namespace) Store6CoreStoreNamespace *namespace_ __attribute__((swift_name("namespace_"))); +@end + +__attribute__((swift_name("StoreMeta"))) +@protocol Store6CoreStoreMeta +@required +@property (readonly) NSString * _Nullable etag __attribute__((swift_name("etag"))); +@property (readonly) int64_t writtenAtEpochMillis __attribute__((swift_name("writtenAtEpochMillis"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreNamespace"))) +@interface Store6CoreStoreNamespace : Store6CoreBase +- (instancetype)initWithValue:(NSString *)value __attribute__((swift_name("init(value:)"))) __attribute__((objc_designated_initializer)); +@property (readonly) NSString *value __attribute__((swift_name("value"))); +@end + +__attribute__((swift_name("StoreResult"))) +@protocol Store6CoreStoreResult +@required +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreResultData"))) +@interface Store6CoreStoreResultData : Store6CoreBase +@property (readonly) int64_t age __attribute__((swift_name("age"))); +@property (readonly) BOOL isStale __attribute__((swift_name("isStale"))); +@property (readonly) Store6CoreOrigin *origin __attribute__((swift_name("origin"))); +@property (readonly) BOOL refreshing __attribute__((swift_name("refreshing"))); +@property (readonly) V _Nullable value __attribute__((swift_name("value"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreResultError"))) +@interface Store6CoreStoreResultError : Store6CoreBase +@property (readonly) Store6CoreStoreError *error __attribute__((swift_name("error"))); +@property (readonly) BOOL servedStale __attribute__((swift_name("servedStale"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreResultLoading"))) +@interface Store6CoreStoreResultLoading : Store6CoreBase +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreResultRevalidated"))) +@interface Store6CoreStoreResultRevalidated : Store6CoreBase +@property (readonly) int64_t age __attribute__((swift_name("age"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("Bookkeeper"))) +@protocol Store6CoreBookkeeper +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)advanceGlobalStaleWatermarkWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("advanceGlobalStaleWatermark(completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)advanceStaleWatermarkNamespace:(Store6CoreStoreNamespace *)namespace_ completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("advanceStaleWatermark(namespace:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)forgetKey:(id)key completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("forget(key:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)forgetAllWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("forgetAll(completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)forgetNamespaceNamespace:(Store6CoreStoreNamespace *)namespace_ completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("forgetNamespace(namespace:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)markStaleKey:(id)key completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("markStale(key:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)recordFailureKey:(id)key atEpochMillis:(int64_t)atEpochMillis completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("recordFailure(key:atEpochMillis:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)recordSuccessKey:(id)key meta:(id)meta completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("recordSuccess(key:meta:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)statusKey:(id)key completionHandler:(void (^)(Store6CoreKeyStatus * _Nullable_result, NSError * _Nullable))completionHandler __attribute__((swift_name("status(key:completionHandler:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((swift_name("FetchPlan"))) +@protocol Store6CoreFetchPlan +@required +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FetchPlanConditional"))) +@interface Store6CoreFetchPlanConditional : Store6CoreBase +- (instancetype)initWithEtag:(NSString *)etag servesResidentWhileFetching:(BOOL)servesResidentWhileFetching __attribute__((swift_name("init(etag:servesResidentWhileFetching:)"))) __attribute__((objc_designated_initializer)); +@property (readonly) NSString *etag __attribute__((swift_name("etag"))); +@property (readonly) BOOL servesResidentWhileFetching __attribute__((swift_name("servesResidentWhileFetching"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FetchPlanFetch"))) +@interface Store6CoreFetchPlanFetch : Store6CoreBase +- (instancetype)initWithServesResidentWhileFetching:(BOOL)servesResidentWhileFetching __attribute__((swift_name("init(servesResidentWhileFetching:)"))) __attribute__((objc_designated_initializer)); +@property (readonly) BOOL servesResidentWhileFetching __attribute__((swift_name("servesResidentWhileFetching"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FetchPlanSkip"))) +@interface Store6CoreFetchPlanSkip : Store6CoreBase ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)skip __attribute__((swift_name("init()"))); +@property (class, readonly, getter=shared) Store6CoreFetchPlanSkip *shared __attribute__((swift_name("shared"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("Fetcher"))) +@protocol Store6CoreFetcher +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)fetchKey:(id)key etag:(NSString * _Nullable)etag completionHandler:(void (^)(id _Nullable, NSError * _Nullable))completionHandler __attribute__((swift_name("fetch(key:etag:completionHandler:)"))); +@end + +__attribute__((swift_name("FetcherResult"))) +@protocol Store6CoreFetcherResult +@required +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FetcherResultDeleted"))) +@interface Store6CoreFetcherResultDeleted : Store6CoreBase ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)deleted __attribute__((swift_name("init()"))); +@property (class, readonly, getter=shared) Store6CoreFetcherResultDeleted *shared __attribute__((swift_name("shared"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FetcherResultError"))) +@interface Store6CoreFetcherResultError : Store6CoreBase +- (instancetype)initWithCause:(Store6CoreKotlinThrowable *)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)); +@property (readonly) Store6CoreKotlinThrowable *cause __attribute__((swift_name("cause"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FetcherResultNotModified"))) +@interface Store6CoreFetcherResultNotModified : Store6CoreBase +- (instancetype)initWithEtag:(NSString * _Nullable)etag __attribute__((swift_name("init(etag:)"))) __attribute__((objc_designated_initializer)); +@property (readonly) NSString * _Nullable etag __attribute__((swift_name("etag"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FetcherResultSuccess"))) +@interface Store6CoreFetcherResultSuccess : Store6CoreBase +- (instancetype)initWithValue:(V)value etag:(NSString * _Nullable)etag __attribute__((swift_name("init(value:etag:)"))) __attribute__((objc_designated_initializer)); +@property (readonly) NSString * _Nullable etag __attribute__((swift_name("etag"))); +@property (readonly) V value __attribute__((swift_name("value"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FreshnessContext"))) +@interface Store6CoreFreshnessContext : Store6CoreBase +- (instancetype)initWithHasResidentValue:(BOOL)hasResidentValue meta:(id _Nullable)meta epochStale:(BOOL)epochStale freshness:(id)freshness nowEpochMillis:(int64_t)nowEpochMillis status:(Store6CoreKeyStatus * _Nullable)status __attribute__((swift_name("init(hasResidentValue:meta:epochStale:freshness:nowEpochMillis:status:)"))) __attribute__((objc_designated_initializer)); +@property (readonly) BOOL epochStale __attribute__((swift_name("epochStale"))); +@property (readonly) id freshness __attribute__((swift_name("freshness"))); +@property (readonly) BOOL hasResidentValue __attribute__((swift_name("hasResidentValue"))); +@property (readonly) id _Nullable meta __attribute__((swift_name("meta"))); +@property (readonly) int64_t nowEpochMillis __attribute__((swift_name("nowEpochMillis"))); +@property (readonly) Store6CoreKeyStatus * _Nullable status __attribute__((swift_name("status"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("FreshnessValidator"))) +@protocol Store6CoreFreshnessValidator +@required +- (id)planContext:(Store6CoreFreshnessContext *)context __attribute__((swift_name("plan(context:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((swift_name("KeyEvents"))) +@interface Store6CoreKeyEvents : Store6CoreBase +@property (readonly) id key __attribute__((swift_name("key"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("KeyEvents.Deleted"))) +@interface Store6CoreKeyEventsDeleted : Store6CoreKeyEvents +@property (readonly) id key __attribute__((swift_name("key"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("KeyEvents.Invalidated"))) +@interface Store6CoreKeyEventsInvalidated : Store6CoreKeyEvents +@property (readonly) id key __attribute__((swift_name("key"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("KeyEvents.Written"))) +@interface Store6CoreKeyEventsWritten : Store6CoreKeyEvents +@property (readonly) id key __attribute__((swift_name("key"))); +@property (readonly) Store6CoreOrigin *origin __attribute__((swift_name("origin"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("KeyStatus"))) +@interface Store6CoreKeyStatus : Store6CoreBase +- (instancetype)initWithMeta:(id _Nullable)meta lastSuccessSequence:(Store6CoreLong * _Nullable)lastSuccessSequence lastFailureAtEpochMillis:(Store6CoreLong * _Nullable)lastFailureAtEpochMillis consecutiveFailures:(int32_t)consecutiveFailures durablyStale:(BOOL)durablyStale __attribute__((swift_name("init(meta:lastSuccessSequence:lastFailureAtEpochMillis:consecutiveFailures:durablyStale:)"))) __attribute__((objc_designated_initializer)); +@property (readonly) int32_t consecutiveFailures __attribute__((swift_name("consecutiveFailures"))); +@property (readonly) BOOL durablyStale __attribute__((swift_name("durablyStale"))); +@property (readonly) Store6CoreLong * _Nullable lastFailureAtEpochMillis __attribute__((swift_name("lastFailureAtEpochMillis"))); +@property (readonly) Store6CoreLong * _Nullable lastSuccessSequence __attribute__((swift_name("lastSuccessSequence"))); +@property (readonly) id _Nullable meta __attribute__((swift_name("meta"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("Overlay"))) +@protocol Store6CoreOverlay +@required +- (id _Nullable)applyKey:(id)key base:(id _Nullable)base __attribute__((swift_name("apply(key:base:)"))); +@property (readonly) id changes __attribute__((swift_name("changes"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("SourceOfTruth"))) +@protocol Store6CoreSourceOfTruth +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)deleteKey:(id)key completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("delete(key:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)deleteAllWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("deleteAll(completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)deleteNamespaceNamespace:(Store6CoreStoreNamespace *)namespace_ completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("deleteNamespace(namespace:completionHandler:)"))); +- (id)readerKey:(id)key __attribute__((swift_name("reader(key:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)writeKey:(id)key value:(id)value completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("write(key:value:completionHandler:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreResults"))) +@interface Store6CoreStoreResults : Store6CoreBase ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)storeResults __attribute__((swift_name("init()"))); +@property (class, readonly, getter=shared) Store6CoreStoreResults *shared __attribute__((swift_name("shared"))); +- (Store6CoreStoreErrorConflict *)conflictServerMeta:(id _Nullable)serverMeta message:(NSString *)message __attribute__((swift_name("conflict(serverMeta:message:)"))); +- (Store6CoreStoreErrorConversion *)conversionErrorMessage:(NSString *)message cause:(Store6CoreKotlinThrowable * _Nullable)cause __attribute__((swift_name("conversionError(message:cause:)"))); +- (Store6CoreStoreResultData *)dataValue:(id _Nullable)value origin:(Store6CoreOrigin *)origin age:(int64_t)age isStale:(BOOL)isStale refreshing:(BOOL)refreshing __attribute__((swift_name("data(value:origin:age:isStale:refreshing:)"))); +- (Store6CoreStoreResultError *)errorError:(Store6CoreStoreError *)error servedStale:(BOOL)servedStale __attribute__((swift_name("error(error:servedStale:)"))); +- (Store6CoreStoreException *)exceptionError:(Store6CoreStoreError *)error cause:(Store6CoreKotlinThrowable * _Nullable)cause __attribute__((swift_name("exception(error:cause:)"))); +- (Store6CoreStoreErrorFetch *)fetchErrorMessage:(NSString *)message cause:(Store6CoreKotlinThrowable * _Nullable)cause __attribute__((swift_name("fetchError(message:cause:)"))); +- (Store6CoreStoreErrorFreshnessUnsatisfiable *)freshnessUnsatisfiableMessage:(NSString *)message __attribute__((swift_name("freshnessUnsatisfiable(message:)"))); +- (Store6CoreStoreResultLoading *)loading __attribute__((swift_name("loading()"))); +- (Store6CoreStoreErrorMissing *)missingKey:(id)key message:(NSString *)message __attribute__((swift_name("missing(key:message:)"))); +- (Store6CoreStoreErrorPersistence *)persistenceErrorMessage:(NSString *)message cause:(Store6CoreKotlinThrowable * _Nullable)cause __attribute__((swift_name("persistenceError(message:cause:)"))); +- (Store6CoreStoreResultRevalidated *)revalidatedAge:(int64_t)age __attribute__((swift_name("revalidated(age:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("StoreRuntime"))) +@protocol Store6CoreStoreRuntime +@required +@property (readonly) id keyEvents __attribute__((swift_name("keyEvents"))); +@property (readonly) id _Nullable telemetry __attribute__((swift_name("telemetry"))); +@property (readonly) id writeHandle __attribute__((swift_name("writeHandle"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("StoreTelemetry"))) +@protocol Store6CoreStoreTelemetry +@required +- (void)onClearedKey:(id)key __attribute__((swift_name("onCleared(key:)"))); +- (void)onFetchFailedKey:(id)key error:(Store6CoreStoreError *)error duration:(int64_t)duration __attribute__((swift_name("onFetchFailed(key:error:duration:)"))); +- (void)onFetchStartedKey:(id)key __attribute__((swift_name("onFetchStarted(key:)"))); +- (void)onFetchSucceededKey:(id)key duration:(int64_t)duration __attribute__((swift_name("onFetchSucceeded(key:duration:)"))); +- (void)onInvalidatedKey:(id)key __attribute__((swift_name("onInvalidated(key:)"))); +- (void)onServeKey:(id)key origin:(Store6CoreOrigin *)origin __attribute__((swift_name("onServe(key:origin:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("StoreWriteHandle"))) +@protocol Store6CoreStoreWriteHandle +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)applyKey:(id)key value:(id)value completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("apply(key:value:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)confirmFreshKey:(id)key etag:(NSString * _Nullable)etag completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("confirmFresh(key:etag:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)markStaleKey:(id)key completionHandler_:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("markStale(key:completionHandler_:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("TransactionalSourceOfTruth"))) +@protocol Store6CoreTransactionalSourceOfTruth +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)withTransactionBlock:(id)block completionHandler:(void (^)(id _Nullable_result, NSError * _Nullable))completionHandler __attribute__((swift_name("withTransaction(block:completionHandler:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("WallClock"))) +@protocol Store6CoreWallClock +@required +- (int64_t)nowEpochMillis __attribute__((swift_name("nowEpochMillis()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreBuilderKt"))) +@interface Store6CoreStoreBuilderKt : Store6CoreBase ++ (id)storeConfigure:(void (^)(Store6CoreStoreBuilder, id> *))configure __attribute__((swift_name("store(configure:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreRuntimeKt"))) +@interface Store6CoreStoreRuntimeKt : Store6CoreBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ ++ (id _Nullable)runtime:(id)receiver __attribute__((swift_name("runtime(_:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("KotlinEnumCompanion"))) +@interface Store6CoreKotlinEnumCompanion : Store6CoreBase ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)companion __attribute__((swift_name("init()"))); +@property (class, readonly, getter=shared) Store6CoreKotlinEnumCompanion *shared __attribute__((swift_name("shared"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("KotlinArray"))) +@interface Store6CoreKotlinArray : Store6CoreBase ++ (instancetype)arrayWithSize:(int32_t)size init:(T _Nullable (^)(Store6CoreInt *))init __attribute__((swift_name("init(size:init:)"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); +- (T _Nullable)getIndex:(int32_t)index __attribute__((swift_name("get(index:)"))); +- (id)iterator __attribute__((swift_name("iterator()"))); +- (void)setIndex:(int32_t)index value:(T _Nullable)value __attribute__((swift_name("set(index:value:)"))); +@property (readonly) int32_t size __attribute__((swift_name("size"))); +@end + +__attribute__((swift_name("KotlinIllegalStateException"))) +@interface Store6CoreKotlinIllegalStateException : Store6CoreKotlinRuntimeException +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); ++ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); +- (instancetype)initWithMessage:(NSString * _Nullable)message __attribute__((swift_name("init(message:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithCause:(Store6CoreKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithMessage:(NSString * _Nullable)message cause:(Store6CoreKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(message:cause:)"))) __attribute__((objc_designated_initializer)); +@end + + +/** + * @note annotations + * kotlin.SinceKotlin(version="1.4") +*/ +__attribute__((swift_name("KotlinCancellationException"))) +@interface Store6CoreKotlinCancellationException : Store6CoreKotlinIllegalStateException +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); ++ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); +- (instancetype)initWithMessage:(NSString * _Nullable)message __attribute__((swift_name("init(message:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithCause:(Store6CoreKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithMessage:(NSString * _Nullable)message cause:(Store6CoreKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(message:cause:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((swift_name("Kotlinx_coroutines_coreFlow"))) +@protocol Store6CoreKotlinx_coroutines_coreFlow +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)collectCollector:(id)collector completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("collect(collector:completionHandler:)"))); +@end + +__attribute__((swift_name("KotlinFunction"))) +@protocol Store6CoreKotlinFunction +@required +@end + +__attribute__((swift_name("KotlinSuspendFunction1"))) +@protocol Store6CoreKotlinSuspendFunction1 +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)invokeP1:(id _Nullable)p1 completionHandler:(void (^)(id _Nullable_result, NSError * _Nullable))completionHandler __attribute__((swift_name("invoke(p1:completionHandler:)"))); +@end + +__attribute__((swift_name("KotlinSuspendFunction0"))) +@protocol Store6CoreKotlinSuspendFunction0 +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)invokeWithCompletionHandler:(void (^)(id _Nullable_result, NSError * _Nullable))completionHandler __attribute__((swift_name("invoke(completionHandler:)"))); +@end + +__attribute__((swift_name("KotlinIterator"))) +@protocol Store6CoreKotlinIterator +@required +- (BOOL)hasNext __attribute__((swift_name("hasNext()"))); +- (id _Nullable)next __attribute__((swift_name("next()"))); +@end + +__attribute__((swift_name("Kotlinx_coroutines_coreFlowCollector"))) +@protocol Store6CoreKotlinx_coroutines_coreFlowCollector +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)emitValue:(id _Nullable)value completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("emit(value:completionHandler:)"))); +@end + +#pragma pop_macro("_Nullable_result") +#pragma clang diagnostic pop +NS_ASSUME_NONNULL_END diff --git a/store6-core/api/swift/skie/Store6CoreSkie.h b/store6-core/api/swift/skie/Store6CoreSkie.h new file mode 100644 index 000000000..a6fc0e587 --- /dev/null +++ b/store6-core/api/swift/skie/Store6CoreSkie.h @@ -0,0 +1,1358 @@ +#import +#import +#import +#import +#import +#import +#import + +@class SCS__SkieSuspendWrappersKt, SCSUShort, SCSULong, SCSUInt, SCSUByte, SCSStoreRuntimeKt, SCSStoreResults, SCSStoreResultRevalidated, SCSStoreResultLoading, SCSStoreResultError, SCSStoreResultData, SCSStoreNamespace, SCSStoreException, SCSStoreErrorPersistence, SCSStoreErrorMissing, SCSStoreErrorFreshnessUnsatisfiable, SCSStoreErrorFetch, SCSStoreErrorConversion, SCSStoreErrorConflict, SCSStoreError, SCSStoreBuilderKt, SCSStoreBuilder, SCSSkie_SuspendResultSuccess, SCSSkie_SuspendResultError, SCSSkie_SuspendResultCanceled, SCSSkie_SuspendResult, SCSSkie_SuspendHandler, SCSSkie_CancellationHandler, SCSSkieKotlinStateFlow, SCSSkieKotlinSharedFlow, SCSSkieKotlinOptionalStateFlow, SCSSkieKotlinOptionalSharedFlow, SCSSkieKotlinOptionalMutableStateFlow, SCSSkieKotlinOptionalMutableSharedFlow, SCSSkieKotlinOptionalFlow, SCSSkieKotlinMutableStateFlow, SCSSkieKotlinMutableSharedFlow, SCSSkieKotlinFlow, SCSSkieColdFlowIterator, SCSShort, SCSOrigin, SCSNumber, SCSMutableSet, SCSMutableDictionary, SCSLong, SCSKotlinThrowable, SCSKotlinRuntimeException, SCSKotlinIllegalStateException, SCSKotlinException, SCSKotlinEnumCompanion, SCSKotlinEnum, SCSKotlinCancellationException, SCSKotlinArray, SCSKeyStatus, SCSKeyEventsWritten, SCSKeyEventsInvalidated, SCSKeyEventsDeleted, SCSKeyEvents, SCSInt, SCSFreshnessStaleIfError, SCSFreshnessMustBeFresh, SCSFreshnessMaxAge, SCSFreshnessLocalOnly, SCSFreshnessContext, SCSFreshnessCachedOrFetch, SCSFloat, SCSFetcherResultSuccess, SCSFetcherResultNotModified, SCSFetcherResultError, SCSFetcherResultDeleted, SCSFetchPlanSkip, SCSFetchPlanFetch, SCSFetchPlanConditional, SCSDouble, SCSByte, SCSBoolean, SCSBase, NSString, NSSet, NSObject, NSNumber, NSMutableSet, NSMutableDictionary, NSMutableArray, NSError, NSDictionary, NSArray; + +@protocol SCSWallClock, SCSTransactionalSourceOfTruth, SCSStoreWriteHandle, SCSStoreTelemetry, SCSStoreRuntime, SCSStoreResult, SCSStoreMeta, SCSStoreKey, SCSStore, SCSSourceOfTruth, SCSSkie_DispatcherDelegate, SCSOverlay, SCSKotlinx_coroutines_coreStateFlow, SCSKotlinx_coroutines_coreSharedFlow, SCSKotlinx_coroutines_coreRunnable, SCSKotlinx_coroutines_coreMutableStateFlow, SCSKotlinx_coroutines_coreMutableSharedFlow, SCSKotlinx_coroutines_coreFlowCollector, SCSKotlinx_coroutines_coreFlow, SCSKotlinSuspendFunction1, SCSKotlinSuspendFunction0, SCSKotlinIterator, SCSKotlinFunction, SCSKotlinComparable, SCSFreshnessValidator, SCSFreshness, SCSFetcherResult, SCSFetcher, SCSFetchPlan, SCSBookkeeper, NSCopying; + +// Due to an Obj-C/Swift interop limitation, SKIE cannot generate Swift types with a lambda type argument. +// Example of such type is: A<() -> Unit> where A is a generic class. +// To avoid compilation errors SKIE replaces these type arguments with __SkieLambdaErrorType, resulting in A<__SkieLambdaErrorType>. +// Generated declarations that reference __SkieLambdaErrorType cannot be called in any way and the __SkieLambdaErrorType class cannot be used. +// The original declarations can still be used in the same way as other declarations hidden by SKIE (and with the same limitations as without SKIE). +@interface __SkieLambdaErrorType : NSObject +- (instancetype _Nonnull)init __attribute__((unavailable)); ++ (instancetype _Nonnull)new __attribute__((unavailable)); +@end + +// Due to an Obj-C/Swift interop limitation, SKIE cannot generate Swift code that uses external Obj-C types for which SKIE doesn't know a fully qualified name. +// This problem occurs when custom Cinterop bindings are used because those do not contain the name of the Framework that provides implementation for those binding. +// The name can be configured manually using the SKIE Gradle configuration key 'ClassInterop.CInteropFrameworkName' in the same way as other SKIE features. +// To avoid compilation errors SKIE replaces types with unknown Framework name with __SkieUnknownCInteropFrameworkErrorType. +// Generated declarations that reference __SkieUnknownCInteropFrameworkErrorType cannot be called in any way and the __SkieUnknownCInteropFrameworkErrorType class cannot be used. +@interface __SkieUnknownCInteropFrameworkErrorType : NSObject +- (instancetype _Nonnull)init __attribute__((unavailable)); ++ (instancetype _Nonnull)new __attribute__((unavailable)); +@end + + +NS_ASSUME_NONNULL_BEGIN +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wunknown-warning-option" +#pragma clang diagnostic ignored "-Wincompatible-property-type" +#pragma clang diagnostic ignored "-Wnullability" + +#pragma push_macro("_Nullable_result") +#if !__has_feature(nullability_nullable_result) +#undef _Nullable_result +#define _Nullable_result _Nullable +#endif + +__attribute__((swift_name("KotlinBase"))) +@interface SCSBase : NSObject +- (instancetype)init __attribute__((unavailable)); ++ (instancetype)new __attribute__((unavailable)); ++ (void)initialize __attribute__((objc_requires_super)); +@end + +@interface SCSBase (SCSBaseCopying) +@end + +__attribute__((swift_name("KotlinMutableSet"))) +@interface SCSMutableSet : NSMutableSet +@end + +__attribute__((swift_name("KotlinMutableDictionary"))) +@interface SCSMutableDictionary : NSMutableDictionary +@end + +@interface NSError (NSErrorSCSKotlinException) +@property (readonly) id _Nullable kotlinException; +@end + +__attribute__((swift_name("KotlinNumber"))) +@interface SCSNumber : NSNumber +- (instancetype)initWithChar:(char)value __attribute__((unavailable)); +- (instancetype)initWithUnsignedChar:(unsigned char)value __attribute__((unavailable)); +- (instancetype)initWithShort:(short)value __attribute__((unavailable)); +- (instancetype)initWithUnsignedShort:(unsigned short)value __attribute__((unavailable)); +- (instancetype)initWithInt:(int)value __attribute__((unavailable)); +- (instancetype)initWithUnsignedInt:(unsigned int)value __attribute__((unavailable)); +- (instancetype)initWithLong:(long)value __attribute__((unavailable)); +- (instancetype)initWithUnsignedLong:(unsigned long)value __attribute__((unavailable)); +- (instancetype)initWithLongLong:(long long)value __attribute__((unavailable)); +- (instancetype)initWithUnsignedLongLong:(unsigned long long)value __attribute__((unavailable)); +- (instancetype)initWithFloat:(float)value __attribute__((unavailable)); +- (instancetype)initWithDouble:(double)value __attribute__((unavailable)); +- (instancetype)initWithBool:(BOOL)value __attribute__((unavailable)); +- (instancetype)initWithInteger:(NSInteger)value __attribute__((unavailable)); +- (instancetype)initWithUnsignedInteger:(NSUInteger)value __attribute__((unavailable)); ++ (instancetype)numberWithChar:(char)value __attribute__((unavailable)); ++ (instancetype)numberWithUnsignedChar:(unsigned char)value __attribute__((unavailable)); ++ (instancetype)numberWithShort:(short)value __attribute__((unavailable)); ++ (instancetype)numberWithUnsignedShort:(unsigned short)value __attribute__((unavailable)); ++ (instancetype)numberWithInt:(int)value __attribute__((unavailable)); ++ (instancetype)numberWithUnsignedInt:(unsigned int)value __attribute__((unavailable)); ++ (instancetype)numberWithLong:(long)value __attribute__((unavailable)); ++ (instancetype)numberWithUnsignedLong:(unsigned long)value __attribute__((unavailable)); ++ (instancetype)numberWithLongLong:(long long)value __attribute__((unavailable)); ++ (instancetype)numberWithUnsignedLongLong:(unsigned long long)value __attribute__((unavailable)); ++ (instancetype)numberWithFloat:(float)value __attribute__((unavailable)); ++ (instancetype)numberWithDouble:(double)value __attribute__((unavailable)); ++ (instancetype)numberWithBool:(BOOL)value __attribute__((unavailable)); ++ (instancetype)numberWithInteger:(NSInteger)value __attribute__((unavailable)); ++ (instancetype)numberWithUnsignedInteger:(NSUInteger)value __attribute__((unavailable)); +@end + +__attribute__((swift_name("KotlinByte"))) +@interface SCSByte : SCSNumber +- (instancetype)initWithChar:(char)value; ++ (instancetype)numberWithChar:(char)value; +@end + +__attribute__((swift_name("KotlinUByte"))) +@interface SCSUByte : SCSNumber +- (instancetype)initWithUnsignedChar:(unsigned char)value; ++ (instancetype)numberWithUnsignedChar:(unsigned char)value; +@end + +__attribute__((swift_name("KotlinShort"))) +@interface SCSShort : SCSNumber +- (instancetype)initWithShort:(short)value; ++ (instancetype)numberWithShort:(short)value; +@end + +__attribute__((swift_name("KotlinUShort"))) +@interface SCSUShort : SCSNumber +- (instancetype)initWithUnsignedShort:(unsigned short)value; ++ (instancetype)numberWithUnsignedShort:(unsigned short)value; +@end + +__attribute__((swift_name("KotlinInt"))) +@interface SCSInt : SCSNumber +- (instancetype)initWithInt:(int)value; ++ (instancetype)numberWithInt:(int)value; +@end + +__attribute__((swift_name("KotlinUInt"))) +@interface SCSUInt : SCSNumber +- (instancetype)initWithUnsignedInt:(unsigned int)value; ++ (instancetype)numberWithUnsignedInt:(unsigned int)value; +@end + +__attribute__((swift_name("KotlinLong"))) +@interface SCSLong : SCSNumber +- (instancetype)initWithLongLong:(long long)value; ++ (instancetype)numberWithLongLong:(long long)value; +@end + +__attribute__((swift_name("KotlinULong"))) +@interface SCSULong : SCSNumber +- (instancetype)initWithUnsignedLongLong:(unsigned long long)value; ++ (instancetype)numberWithUnsignedLongLong:(unsigned long long)value; +@end + +__attribute__((swift_name("KotlinFloat"))) +@interface SCSFloat : SCSNumber +- (instancetype)initWithFloat:(float)value; ++ (instancetype)numberWithFloat:(float)value; +@end + +__attribute__((swift_name("KotlinDouble"))) +@interface SCSDouble : SCSNumber +- (instancetype)initWithDouble:(double)value; ++ (instancetype)numberWithDouble:(double)value; +@end + +__attribute__((swift_name("KotlinBoolean"))) +@interface SCSBoolean : SCSNumber +- (instancetype)initWithBool:(BOOL)value; ++ (instancetype)numberWithBool:(BOOL)value; +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("SkieColdFlowIterator"))) +@interface SCSSkieColdFlowIterator : SCSBase +- (instancetype)initWithFlow:(id)flow __attribute__((swift_name("init(flow:)"))) __attribute__((objc_designated_initializer)); +- (void)cancel __attribute__((swift_name("cancel()"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)hasNextWithCompletionHandler:(void (^)(SCSBoolean * _Nullable, NSError * _Nullable))completionHandler __attribute__((swift_name("hasNext(completionHandler:)"))); +- (E _Nullable)next __attribute__((swift_name("next()"))); +@end + +__attribute__((swift_name("Kotlinx_coroutines_coreFlow"))) +@protocol SCSKotlinx_coroutines_coreFlow +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)collectCollector:(id)collector completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("collect(collector:completionHandler:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("SkieKotlinFlow"))) +@interface SCSSkieKotlinFlow<__covariant T> : SCSBase +- (instancetype)initWithDelegate:(id)delegate __attribute__((swift_name("init(_:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)collectCollector:(id)collector completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("collect(collector:completionHandler:)"))); +@end + +__attribute__((swift_name("Kotlinx_coroutines_coreSharedFlow"))) +@protocol SCSKotlinx_coroutines_coreSharedFlow +@required +@property (readonly) NSArray *replayCache __attribute__((swift_name("replayCache"))); +@end + +__attribute__((swift_name("Kotlinx_coroutines_coreFlowCollector"))) +@protocol SCSKotlinx_coroutines_coreFlowCollector +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)emitValue:(id _Nullable)value completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("emit(value:completionHandler:)"))); +@end + +__attribute__((swift_name("Kotlinx_coroutines_coreMutableSharedFlow"))) +@protocol SCSKotlinx_coroutines_coreMutableSharedFlow +@required + +/** + * @note annotations + * kotlinx.coroutines.ExperimentalCoroutinesApi +*/ +- (void)resetReplayCache __attribute__((swift_name("resetReplayCache()"))); +- (BOOL)tryEmitValue:(id _Nullable)value __attribute__((swift_name("tryEmit(value:)"))); +@property (readonly) id subscriptionCount __attribute__((swift_name("subscriptionCount"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("SkieKotlinMutableSharedFlow"))) +@interface SCSSkieKotlinMutableSharedFlow : SCSBase +@property (readonly) NSArray *replayCache __attribute__((swift_name("replayCache"))); +@property (readonly) id subscriptionCount __attribute__((swift_name("subscriptionCount"))); +- (instancetype)initWithDelegate:(id)delegate __attribute__((swift_name("init(_:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)collectCollector:(id)collector completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("collect(collector:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)emitValue:(T)value completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("emit(value:completionHandler:)"))); + +/** + * @note annotations + * kotlinx.coroutines.ExperimentalCoroutinesApi +*/ +- (void)resetReplayCache __attribute__((swift_name("resetReplayCache()"))); +- (BOOL)tryEmitValue:(T)value __attribute__((swift_name("tryEmit(value:)"))); +@end + +__attribute__((swift_name("Kotlinx_coroutines_coreStateFlow"))) +@protocol SCSKotlinx_coroutines_coreStateFlow +@required +@property (readonly) id _Nullable value __attribute__((swift_name("value"))); +@end + +__attribute__((swift_name("Kotlinx_coroutines_coreMutableStateFlow"))) +@protocol SCSKotlinx_coroutines_coreMutableStateFlow +@required +- (void)setValue:(id _Nullable)value __attribute__((swift_name("setValue(_:)"))); +- (BOOL)compareAndSetExpect:(id _Nullable)expect update:(id _Nullable)update __attribute__((swift_name("compareAndSet(expect:update:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("SkieKotlinMutableStateFlow"))) +@interface SCSSkieKotlinMutableStateFlow : SCSBase +@property (readonly) NSArray *replayCache __attribute__((swift_name("replayCache"))); +@property (readonly) id subscriptionCount __attribute__((swift_name("subscriptionCount"))); +@property T value __attribute__((swift_name("value"))); +- (instancetype)initWithDelegate:(id)delegate __attribute__((swift_name("init(_:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)collectCollector:(id)collector completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("collect(collector:completionHandler:)"))); +- (BOOL)compareAndSetExpect:(T)expect update:(T)update __attribute__((swift_name("compareAndSet(expect:update:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)emitValue:(T)value completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("emit(value:completionHandler:)"))); + +/** + * @note annotations + * kotlinx.coroutines.ExperimentalCoroutinesApi +*/ +- (void)resetReplayCache __attribute__((swift_name("resetReplayCache()"))); +- (BOOL)tryEmitValue:(T)value __attribute__((swift_name("tryEmit(value:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("SkieKotlinOptionalFlow"))) +@interface SCSSkieKotlinOptionalFlow<__covariant T> : SCSBase +- (instancetype)initWithDelegate:(id)delegate __attribute__((swift_name("init(_:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)collectCollector:(id)collector completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("collect(collector:completionHandler:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("SkieKotlinOptionalMutableSharedFlow"))) +@interface SCSSkieKotlinOptionalMutableSharedFlow : SCSBase +@property (readonly) NSArray *replayCache __attribute__((swift_name("replayCache"))); +@property (readonly) id subscriptionCount __attribute__((swift_name("subscriptionCount"))); +- (instancetype)initWithDelegate:(id)delegate __attribute__((swift_name("init(_:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)collectCollector:(id)collector completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("collect(collector:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)emitValue:(T _Nullable)value completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("emit(value:completionHandler:)"))); + +/** + * @note annotations + * kotlinx.coroutines.ExperimentalCoroutinesApi +*/ +- (void)resetReplayCache __attribute__((swift_name("resetReplayCache()"))); +- (BOOL)tryEmitValue:(T _Nullable)value __attribute__((swift_name("tryEmit(value:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("SkieKotlinOptionalMutableStateFlow"))) +@interface SCSSkieKotlinOptionalMutableStateFlow : SCSBase +@property (readonly) NSArray *replayCache __attribute__((swift_name("replayCache"))); +@property (readonly) id subscriptionCount __attribute__((swift_name("subscriptionCount"))); +@property T _Nullable value __attribute__((swift_name("value"))); +- (instancetype)initWithDelegate:(id)delegate __attribute__((swift_name("init(_:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)collectCollector:(id)collector completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("collect(collector:completionHandler:)"))); +- (BOOL)compareAndSetExpect:(T _Nullable)expect update:(T _Nullable)update __attribute__((swift_name("compareAndSet(expect:update:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)emitValue:(T _Nullable)value completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("emit(value:completionHandler:)"))); + +/** + * @note annotations + * kotlinx.coroutines.ExperimentalCoroutinesApi +*/ +- (void)resetReplayCache __attribute__((swift_name("resetReplayCache()"))); +- (BOOL)tryEmitValue:(T _Nullable)value __attribute__((swift_name("tryEmit(value:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("SkieKotlinOptionalSharedFlow"))) +@interface SCSSkieKotlinOptionalSharedFlow<__covariant T> : SCSBase +@property (readonly) NSArray *replayCache __attribute__((swift_name("replayCache"))); +- (instancetype)initWithDelegate:(id)delegate __attribute__((swift_name("init(_:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)collectCollector:(id)collector completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("collect(collector:completionHandler:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("SkieKotlinOptionalStateFlow"))) +@interface SCSSkieKotlinOptionalStateFlow<__covariant T> : SCSBase +@property (readonly) NSArray *replayCache __attribute__((swift_name("replayCache"))); +@property (readonly) T _Nullable value __attribute__((swift_name("value"))); +- (instancetype)initWithDelegate:(id)delegate __attribute__((swift_name("init(_:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)collectCollector:(id)collector completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("collect(collector:completionHandler:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("SkieKotlinSharedFlow"))) +@interface SCSSkieKotlinSharedFlow<__covariant T> : SCSBase +@property (readonly) NSArray *replayCache __attribute__((swift_name("replayCache"))); +- (instancetype)initWithDelegate:(id)delegate __attribute__((swift_name("init(_:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)collectCollector:(id)collector completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("collect(collector:completionHandler:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("SkieKotlinStateFlow"))) +@interface SCSSkieKotlinStateFlow<__covariant T> : SCSBase +@property (readonly) NSArray *replayCache __attribute__((swift_name("replayCache"))); +@property (readonly) T value __attribute__((swift_name("value"))); +- (instancetype)initWithDelegate:(id)delegate __attribute__((swift_name("init(_:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)collectCollector:(id)collector completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("collect(collector:completionHandler:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Skie_CancellationHandler"))) +@interface SCSSkie_CancellationHandler : SCSBase +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); ++ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); +- (void)cancel __attribute__((swift_name("cancel()"))); +@end + +__attribute__((swift_name("Skie_DispatcherDelegate"))) +@protocol SCSSkie_DispatcherDelegate +@required +- (void)dispatchBlock:(id)block __attribute__((swift_name("dispatch(block:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Skie_SuspendHandler"))) +@interface SCSSkie_SuspendHandler : SCSBase +- (instancetype)initWithCancellationHandler:(SCSSkie_CancellationHandler *)cancellationHandler dispatcherDelegate:(id)dispatcherDelegate onResult:(void (^)(SCSSkie_SuspendResult *))onResult __attribute__((swift_name("init(cancellationHandler:dispatcherDelegate:onResult:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((swift_name("Skie_SuspendResult"))) +@interface SCSSkie_SuspendResult : SCSBase +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Skie_SuspendResult.Canceled"))) +@interface SCSSkie_SuspendResultCanceled : SCSSkie_SuspendResult +@property (class, readonly, getter=shared) SCSSkie_SuspendResultCanceled *shared __attribute__((swift_name("shared"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)canceled __attribute__((swift_name("init()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Skie_SuspendResult.Error"))) +@interface SCSSkie_SuspendResultError : SCSSkie_SuspendResult +@property (readonly) NSError *error __attribute__((swift_name("error"))); +- (instancetype)initWithError:(NSError *)error __attribute__((swift_name("init(error:)"))) __attribute__((objc_designated_initializer)); +- (SCSSkie_SuspendResultError *)doCopyError:(NSError *)error __attribute__((swift_name("doCopy(error:)"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Skie_SuspendResult.Success"))) +@interface SCSSkie_SuspendResultSuccess : SCSSkie_SuspendResult +@property (readonly) id _Nullable value __attribute__((swift_name("value"))); +- (instancetype)initWithValue:(id _Nullable)value __attribute__((swift_name("init(value:)"))) __attribute__((objc_designated_initializer)); +- (SCSSkie_SuspendResultSuccess *)doCopyValue:(id _Nullable)value __attribute__((swift_name("doCopy(value:)"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + +__attribute__((swift_name("Freshness"))) +@protocol SCSFreshness +@required +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FreshnessCachedOrFetch"))) +@interface SCSFreshnessCachedOrFetch : SCSBase +@property (class, readonly, getter=shared) SCSFreshnessCachedOrFetch *shared __attribute__((swift_name("shared"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)cachedOrFetch __attribute__((swift_name("init()"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FreshnessLocalOnly"))) +@interface SCSFreshnessLocalOnly : SCSBase +@property (class, readonly, getter=shared) SCSFreshnessLocalOnly *shared __attribute__((swift_name("shared"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)localOnly __attribute__((swift_name("init()"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FreshnessMaxAge"))) +@interface SCSFreshnessMaxAge : SCSBase +@property (readonly) int64_t notOlderThan __attribute__((swift_name("notOlderThan"))); +- (instancetype)initWithNotOlderThan:(int64_t)notOlderThan __attribute__((swift_name("init(notOlderThan:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FreshnessMustBeFresh"))) +@interface SCSFreshnessMustBeFresh : SCSBase +@property (class, readonly, getter=shared) SCSFreshnessMustBeFresh *shared __attribute__((swift_name("shared"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)mustBeFresh __attribute__((swift_name("init()"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FreshnessStaleIfError"))) +@interface SCSFreshnessStaleIfError : SCSBase +@property (class, readonly, getter=shared) SCSFreshnessStaleIfError *shared __attribute__((swift_name("shared"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)staleIfError __attribute__((swift_name("init()"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + +__attribute__((swift_name("KotlinComparable"))) +@protocol SCSKotlinComparable +@required +- (int32_t)compareToOther:(id _Nullable)other __attribute__((swift_name("compareTo(other:)"))); +@end + +__attribute__((swift_name("KotlinEnum"))) +@interface SCSKotlinEnum : SCSBase +@property (class, readonly, getter=companion) SCSKotlinEnumCompanion *companion __attribute__((swift_name("companion"))); +@property (readonly) NSString *name __attribute__((swift_name("name"))); +@property (readonly) int32_t ordinal __attribute__((swift_name("ordinal"))); +- (instancetype)initWithName:(NSString *)name ordinal:(int32_t)ordinal __attribute__((swift_name("init(name:ordinal:)"))) __attribute__((objc_designated_initializer)); +- (int32_t)compareToOther:(E)other __attribute__((swift_name("compareTo(other:)"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Origin"))) +@interface SCSOrigin : SCSKotlinEnum +@property (class, readonly) SCSOrigin *memory __attribute__((swift_name("memory"))); +@property (class, readonly) SCSOrigin *sot __attribute__((swift_name("sot"))); +@property (class, readonly) SCSOrigin *fetcher __attribute__((swift_name("fetcher"))); +@property (class, readonly) SCSOrigin *overlay __attribute__((swift_name("overlay"))); +@property (class, readonly) NSArray *entries __attribute__((swift_name("entries"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); +- (instancetype)initWithName:(NSString *)name ordinal:(int32_t)ordinal __attribute__((swift_name("init(name:ordinal:)"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable)); ++ (SCSKotlinArray *)values __attribute__((swift_name("values()"))); +@end + + +/** + * @note annotations + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("Store"))) +@protocol SCSStore +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)clearKey:(id)key completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("clear(key:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)clearAllWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("clearAll(completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)clearNamespaceNamespace:(SCSStoreNamespace *)namespace_ completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("clearNamespace(namespace:completionHandler:)"))); +- (void)close __attribute__((swift_name("close()"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)getKey:(id)key freshness:(id)freshness completionHandler:(void (^)(id _Nullable, NSError * _Nullable))completionHandler __attribute__((swift_name("get(key:freshness:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)invalidateKey:(id)key completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("invalidate(key:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)invalidateAllWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("invalidateAll(completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)invalidateNamespaceNamespace:(SCSStoreNamespace *)namespace_ completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("invalidateNamespace(namespace:completionHandler:)"))); +- (id)streamKey:(id)key freshness:(id)freshness __attribute__((swift_name("stream(key:freshness:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreBuilder"))) +@interface SCSStoreBuilder : SCSBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)bookkeeperBookkeeper:(id)bookkeeper __attribute__((swift_name("bookkeeper(bookkeeper:)"))); +- (void)fetcherFetch:(id)fetch __attribute__((swift_name("fetcher(fetch:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)fetcherFetcher:(id)fetcher __attribute__((swift_name("fetcher(fetcher:)"))); +- (void)fetcherOfResultFetch:(id)fetch __attribute__((swift_name("fetcherOfResult(fetch:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)freshnessValidatorValidator:(id)validator __attribute__((swift_name("freshnessValidator(validator:)"))); +- (void)maxIdleKeysCount:(int32_t)count __attribute__((swift_name("maxIdleKeys(count:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)overlayOverlay:(id)overlay __attribute__((swift_name("overlay(overlay:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)persistenceSot:(id)sot __attribute__((swift_name("persistence(sot:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)telemetryTelemetry:(id)telemetry __attribute__((swift_name("telemetry(telemetry:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)wallClockWallClock:(id)wallClock __attribute__((swift_name("wallClock(wallClock:)"))); +@end + +__attribute__((swift_name("StoreError"))) +@interface SCSStoreError : SCSBase +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreError.Conflict"))) +@interface SCSStoreErrorConflict : SCSStoreError +@property (readonly) NSString *message __attribute__((swift_name("message"))); +@property (readonly) id _Nullable serverMeta __attribute__((swift_name("serverMeta"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreError.Conversion"))) +@interface SCSStoreErrorConversion : SCSStoreError +@property (readonly) SCSKotlinThrowable * _Nullable cause __attribute__((swift_name("cause"))); +@property (readonly) NSString *message __attribute__((swift_name("message"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreError.Fetch"))) +@interface SCSStoreErrorFetch : SCSStoreError +@property (readonly) SCSKotlinThrowable * _Nullable cause __attribute__((swift_name("cause"))); +@property (readonly) NSString *message __attribute__((swift_name("message"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreError.FreshnessUnsatisfiable"))) +@interface SCSStoreErrorFreshnessUnsatisfiable : SCSStoreError +@property (readonly) NSString *message __attribute__((swift_name("message"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreError.Missing"))) +@interface SCSStoreErrorMissing : SCSStoreError +@property (readonly) id key __attribute__((swift_name("key"))); +@property (readonly) NSString *message __attribute__((swift_name("message"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreError.Persistence"))) +@interface SCSStoreErrorPersistence : SCSStoreError +@property (readonly) SCSKotlinThrowable * _Nullable cause __attribute__((swift_name("cause"))); +@property (readonly) NSString *message __attribute__((swift_name("message"))); +@end + +__attribute__((swift_name("KotlinThrowable"))) +@interface SCSKotlinThrowable : SCSBase +@property (readonly) SCSKotlinThrowable * _Nullable cause __attribute__((swift_name("cause"))); +@property (readonly) NSString * _Nullable message __attribute__((swift_name("message"))); +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); ++ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); +- (instancetype)initWithMessage:(NSString * _Nullable)message __attribute__((swift_name("init(message:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithCause:(SCSKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithMessage:(NSString * _Nullable)message cause:(SCSKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(message:cause:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note annotations + * kotlin.experimental.ExperimentalNativeApi +*/ +- (SCSKotlinArray *)getStackTrace __attribute__((swift_name("getStackTrace()"))); +- (void)printStackTrace __attribute__((swift_name("printStackTrace()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +- (NSError *)asError __attribute__((swift_name("asError()"))); +@end + +__attribute__((swift_name("KotlinException"))) +@interface SCSKotlinException : SCSKotlinThrowable +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); ++ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); +- (instancetype)initWithMessage:(NSString * _Nullable)message __attribute__((swift_name("init(message:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithCause:(SCSKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithMessage:(NSString * _Nullable)message cause:(SCSKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(message:cause:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((swift_name("KotlinRuntimeException"))) +@interface SCSKotlinRuntimeException : SCSKotlinException +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); ++ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); +- (instancetype)initWithMessage:(NSString * _Nullable)message __attribute__((swift_name("init(message:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithCause:(SCSKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithMessage:(NSString * _Nullable)message cause:(SCSKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(message:cause:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreException"))) +@interface SCSStoreException : SCSKotlinRuntimeException +@property (readonly) SCSStoreError *error __attribute__((swift_name("error"))); +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable)); ++ (instancetype)new __attribute__((unavailable)); +- (instancetype)initWithMessage:(NSString * _Nullable)message __attribute__((swift_name("init(message:)"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable)); +- (instancetype)initWithCause:(SCSKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable)); +- (instancetype)initWithMessage:(NSString * _Nullable)message cause:(SCSKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(message:cause:)"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable)); +@end + +__attribute__((swift_name("StoreKey"))) +@protocol SCSStoreKey +@required +- (NSString *)canonicalId __attribute__((swift_name("canonicalId()"))); +@property (readonly, getter=namespace) SCSStoreNamespace *namespace_ __attribute__((swift_name("namespace_"))); +@end + +__attribute__((swift_name("StoreMeta"))) +@protocol SCSStoreMeta +@required +@property (readonly) NSString * _Nullable etag __attribute__((swift_name("etag"))); +@property (readonly) int64_t writtenAtEpochMillis __attribute__((swift_name("writtenAtEpochMillis"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreNamespace"))) +@interface SCSStoreNamespace : SCSBase +@property (readonly) NSString *value __attribute__((swift_name("value"))); +- (instancetype)initWithValue:(NSString *)value __attribute__((swift_name("init(value:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((swift_name("StoreResult"))) +@protocol SCSStoreResult +@required +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreResultData"))) +@interface SCSStoreResultData : SCSBase +@property (readonly) int64_t age __attribute__((swift_name("age"))); +@property (readonly) BOOL isStale __attribute__((swift_name("isStale"))); +@property (readonly) SCSOrigin *origin __attribute__((swift_name("origin"))); +@property (readonly) BOOL refreshing __attribute__((swift_name("refreshing"))); +@property (readonly) V _Nullable value __attribute__((swift_name("value"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreResultError"))) +@interface SCSStoreResultError : SCSBase +@property (readonly) SCSStoreError *error __attribute__((swift_name("error"))); +@property (readonly) BOOL servedStale __attribute__((swift_name("servedStale"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreResultLoading"))) +@interface SCSStoreResultLoading : SCSBase +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreResultRevalidated"))) +@interface SCSStoreResultRevalidated : SCSBase +@property (readonly) int64_t age __attribute__((swift_name("age"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("Bookkeeper"))) +@protocol SCSBookkeeper +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)advanceGlobalStaleWatermarkWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("advanceGlobalStaleWatermark(completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)advanceStaleWatermarkNamespace:(SCSStoreNamespace *)namespace_ completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("advanceStaleWatermark(namespace:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)forgetKey:(id)key completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("forget(key:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)forgetAllWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("forgetAll(completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)forgetNamespaceNamespace:(SCSStoreNamespace *)namespace_ completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("forgetNamespace(namespace:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)markStaleKey:(id)key completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("markStale(key:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)recordFailureKey:(id)key atEpochMillis:(int64_t)atEpochMillis completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("recordFailure(key:atEpochMillis:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)recordSuccessKey:(id)key meta:(id)meta completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("recordSuccess(key:meta:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)statusKey:(id)key completionHandler:(void (^)(SCSKeyStatus * _Nullable_result, NSError * _Nullable))completionHandler __attribute__((swift_name("status(key:completionHandler:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((swift_name("FetchPlan"))) +@protocol SCSFetchPlan +@required +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FetchPlanConditional"))) +@interface SCSFetchPlanConditional : SCSBase +@property (readonly) NSString *etag __attribute__((swift_name("etag"))); +@property (readonly) BOOL servesResidentWhileFetching __attribute__((swift_name("servesResidentWhileFetching"))); +- (instancetype)initWithEtag:(NSString *)etag servesResidentWhileFetching:(BOOL)servesResidentWhileFetching __attribute__((swift_name("init(etag:servesResidentWhileFetching:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FetchPlanFetch"))) +@interface SCSFetchPlanFetch : SCSBase +@property (readonly) BOOL servesResidentWhileFetching __attribute__((swift_name("servesResidentWhileFetching"))); +- (instancetype)initWithServesResidentWhileFetching:(BOOL)servesResidentWhileFetching __attribute__((swift_name("init(servesResidentWhileFetching:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FetchPlanSkip"))) +@interface SCSFetchPlanSkip : SCSBase +@property (class, readonly, getter=shared) SCSFetchPlanSkip *shared __attribute__((swift_name("shared"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)skip __attribute__((swift_name("init()"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("Fetcher"))) +@protocol SCSFetcher +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)fetchKey:(id)key etag:(NSString * _Nullable)etag completionHandler:(void (^)(id _Nullable, NSError * _Nullable))completionHandler __attribute__((swift_name("fetch(key:etag:completionHandler:)"))); +@end + +__attribute__((swift_name("FetcherResult"))) +@protocol SCSFetcherResult +@required +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FetcherResultDeleted"))) +@interface SCSFetcherResultDeleted : SCSBase +@property (class, readonly, getter=shared) SCSFetcherResultDeleted *shared __attribute__((swift_name("shared"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)deleted __attribute__((swift_name("init()"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FetcherResultError"))) +@interface SCSFetcherResultError : SCSBase +@property (readonly) SCSKotlinThrowable *cause __attribute__((swift_name("cause"))); +- (instancetype)initWithCause:(SCSKotlinThrowable *)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FetcherResultNotModified"))) +@interface SCSFetcherResultNotModified : SCSBase +@property (readonly) NSString * _Nullable etag __attribute__((swift_name("etag"))); +- (instancetype)initWithEtag:(NSString * _Nullable)etag __attribute__((swift_name("init(etag:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FetcherResultSuccess"))) +@interface SCSFetcherResultSuccess : SCSBase +@property (readonly) NSString * _Nullable etag __attribute__((swift_name("etag"))); +@property (readonly) V value __attribute__((swift_name("value"))); +- (instancetype)initWithValue:(V)value etag:(NSString * _Nullable)etag __attribute__((swift_name("init(value:etag:)"))) __attribute__((objc_designated_initializer)); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FreshnessContext"))) +@interface SCSFreshnessContext : SCSBase +@property (readonly) BOOL epochStale __attribute__((swift_name("epochStale"))); +@property (readonly) id freshness __attribute__((swift_name("freshness"))); +@property (readonly) BOOL hasResidentValue __attribute__((swift_name("hasResidentValue"))); +@property (readonly) id _Nullable meta __attribute__((swift_name("meta"))); +@property (readonly) int64_t nowEpochMillis __attribute__((swift_name("nowEpochMillis"))); +@property (readonly) SCSKeyStatus * _Nullable status __attribute__((swift_name("status"))); +- (instancetype)initWithHasResidentValue:(BOOL)hasResidentValue meta:(id _Nullable)meta epochStale:(BOOL)epochStale freshness:(id)freshness nowEpochMillis:(int64_t)nowEpochMillis status:(SCSKeyStatus * _Nullable)status __attribute__((swift_name("init(hasResidentValue:meta:epochStale:freshness:nowEpochMillis:status:)"))) __attribute__((objc_designated_initializer)); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("FreshnessValidator"))) +@protocol SCSFreshnessValidator +@required +- (id)planContext:(SCSFreshnessContext *)context __attribute__((swift_name("plan(context:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((swift_name("KeyEvents"))) +@interface SCSKeyEvents : SCSBase +@property (readonly) id key __attribute__((swift_name("key"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("KeyEvents.Deleted"))) +@interface SCSKeyEventsDeleted : SCSKeyEvents +@property (readonly) id key __attribute__((swift_name("key"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("KeyEvents.Invalidated"))) +@interface SCSKeyEventsInvalidated : SCSKeyEvents +@property (readonly) id key __attribute__((swift_name("key"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("KeyEvents.Written"))) +@interface SCSKeyEventsWritten : SCSKeyEvents +@property (readonly) id key __attribute__((swift_name("key"))); +@property (readonly) SCSOrigin *origin __attribute__((swift_name("origin"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("KeyStatus"))) +@interface SCSKeyStatus : SCSBase +@property (readonly) int32_t consecutiveFailures __attribute__((swift_name("consecutiveFailures"))); +@property (readonly) BOOL durablyStale __attribute__((swift_name("durablyStale"))); +@property (readonly) SCSLong * _Nullable lastFailureAtEpochMillis __attribute__((swift_name("lastFailureAtEpochMillis"))); +@property (readonly) SCSLong * _Nullable lastSuccessSequence __attribute__((swift_name("lastSuccessSequence"))); +@property (readonly) id _Nullable meta __attribute__((swift_name("meta"))); +- (instancetype)initWithMeta:(id _Nullable)meta lastSuccessSequence:(SCSLong * _Nullable)lastSuccessSequence lastFailureAtEpochMillis:(SCSLong * _Nullable)lastFailureAtEpochMillis consecutiveFailures:(int32_t)consecutiveFailures durablyStale:(BOOL)durablyStale __attribute__((swift_name("init(meta:lastSuccessSequence:lastFailureAtEpochMillis:consecutiveFailures:durablyStale:)"))) __attribute__((objc_designated_initializer)); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("Overlay"))) +@protocol SCSOverlay +@required +- (id _Nullable)applyKey:(id)key base:(id _Nullable)base __attribute__((swift_name("apply(key:base:)"))); +@property (readonly) id changes __attribute__((swift_name("changes"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("SourceOfTruth"))) +@protocol SCSSourceOfTruth +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)deleteKey:(id)key completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("delete(key:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)deleteAllWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("deleteAll(completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)deleteNamespaceNamespace:(SCSStoreNamespace *)namespace_ completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("deleteNamespace(namespace:completionHandler:)"))); +- (id)readerKey:(id)key __attribute__((swift_name("reader(key:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)writeKey:(id)key value:(id)value completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("write(key:value:completionHandler:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreResults"))) +@interface SCSStoreResults : SCSBase +@property (class, readonly, getter=shared) SCSStoreResults *shared __attribute__((swift_name("shared"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)storeResults __attribute__((swift_name("init()"))); +- (SCSStoreErrorConflict *)conflictServerMeta:(id _Nullable)serverMeta message:(NSString *)message __attribute__((swift_name("conflict(serverMeta:message:)"))); +- (SCSStoreErrorConversion *)conversionErrorMessage:(NSString *)message cause:(SCSKotlinThrowable * _Nullable)cause __attribute__((swift_name("conversionError(message:cause:)"))); +- (SCSStoreResultData *)dataValue:(id _Nullable)value origin:(SCSOrigin *)origin age:(int64_t)age isStale:(BOOL)isStale refreshing:(BOOL)refreshing __attribute__((swift_name("data(value:origin:age:isStale:refreshing:)"))); +- (SCSStoreResultError *)errorError:(SCSStoreError *)error servedStale:(BOOL)servedStale __attribute__((swift_name("error(error:servedStale:)"))); +- (SCSStoreException *)exceptionError:(SCSStoreError *)error cause:(SCSKotlinThrowable * _Nullable)cause __attribute__((swift_name("exception(error:cause:)"))); +- (SCSStoreErrorFetch *)fetchErrorMessage:(NSString *)message cause:(SCSKotlinThrowable * _Nullable)cause __attribute__((swift_name("fetchError(message:cause:)"))); +- (SCSStoreErrorFreshnessUnsatisfiable *)freshnessUnsatisfiableMessage:(NSString *)message __attribute__((swift_name("freshnessUnsatisfiable(message:)"))); +- (SCSStoreResultLoading *)loading __attribute__((swift_name("loading()"))); +- (SCSStoreErrorMissing *)missingKey:(id)key message:(NSString *)message __attribute__((swift_name("missing(key:message:)"))); +- (SCSStoreErrorPersistence *)persistenceErrorMessage:(NSString *)message cause:(SCSKotlinThrowable * _Nullable)cause __attribute__((swift_name("persistenceError(message:cause:)"))); +- (SCSStoreResultRevalidated *)revalidatedAge:(int64_t)age __attribute__((swift_name("revalidated(age:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("StoreRuntime"))) +@protocol SCSStoreRuntime +@required +@property (readonly) id keyEvents __attribute__((swift_name("keyEvents"))); +@property (readonly) id _Nullable telemetry __attribute__((swift_name("telemetry"))); +@property (readonly) id writeHandle __attribute__((swift_name("writeHandle"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("StoreTelemetry"))) +@protocol SCSStoreTelemetry +@required +- (void)onClearedKey:(id)key __attribute__((swift_name("onCleared(key:)"))); +- (void)onFetchFailedKey:(id)key error:(SCSStoreError *)error duration:(int64_t)duration __attribute__((swift_name("onFetchFailed(key:error:duration:)"))); +- (void)onFetchStartedKey:(id)key __attribute__((swift_name("onFetchStarted(key:)"))); +- (void)onFetchSucceededKey:(id)key duration:(int64_t)duration __attribute__((swift_name("onFetchSucceeded(key:duration:)"))); +- (void)onInvalidatedKey:(id)key __attribute__((swift_name("onInvalidated(key:)"))); +- (void)onServeKey:(id)key origin:(SCSOrigin *)origin __attribute__((swift_name("onServe(key:origin:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("StoreWriteHandle"))) +@protocol SCSStoreWriteHandle +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)applyKey:(id)key value:(id)value completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("apply(key:value:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)confirmFreshKey:(id)key etag:(NSString * _Nullable)etag completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("confirmFresh(key:etag:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)markStaleKey:(id)key completionHandler_:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("markStale(key:completionHandler_:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("TransactionalSourceOfTruth"))) +@protocol SCSTransactionalSourceOfTruth +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)withTransactionBlock:(id)block completionHandler:(void (^)(id _Nullable_result, NSError * _Nullable))completionHandler __attribute__((swift_name("withTransaction(block:completionHandler:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("WallClock"))) +@protocol SCSWallClock +@required +- (int64_t)nowEpochMillis __attribute__((swift_name("nowEpochMillis()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreBuilderKt"))) +@interface SCSStoreBuilderKt : SCSBase ++ (id)storeConfigure:(void (^)(SCSStoreBuilder, id> *))configure __attribute__((swift_name("store(configure:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreRuntimeKt"))) +@interface SCSStoreRuntimeKt : SCSBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ ++ (id _Nullable)runtime:(id)receiver __attribute__((swift_name("runtime(_:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("__SkieSuspendWrappersKt"))) +@interface SCS__SkieSuspendWrappersKt : SCSBase ++ (void)Skie_Suspend__0__clearDispatchReceiver:(id)dispatchReceiver key:(id)key suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__0__clear(dispatchReceiver:key:suspendHandler:)"))); ++ (void)Skie_Suspend__10__deleteAllDispatchReceiver:(id)dispatchReceiver suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__10__deleteAll(dispatchReceiver:suspendHandler:)"))); ++ (void)Skie_Suspend__11__deleteNamespaceDispatchReceiver:(id)dispatchReceiver namespace:(SCSStoreNamespace *)namespace_ suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__11__deleteNamespace(dispatchReceiver:namespace:suspendHandler:)"))); ++ (void)Skie_Suspend__12__writeDispatchReceiver:(id)dispatchReceiver key:(id)key value:(id)value suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__12__write(dispatchReceiver:key:value:suspendHandler:)"))); ++ (void)Skie_Suspend__13__invokeDispatchReceiver:(id)dispatchReceiver p1:(id _Nullable)p1 suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__13__invoke(dispatchReceiver:p1:suspendHandler:)"))); ++ (void)Skie_Suspend__14__fetchDispatchReceiver:(id)dispatchReceiver key:(id)key etag:(NSString * _Nullable)etag suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__14__fetch(dispatchReceiver:key:etag:suspendHandler:)"))); ++ (void)Skie_Suspend__15__advanceGlobalStaleWatermarkDispatchReceiver:(id)dispatchReceiver suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__15__advanceGlobalStaleWatermark(dispatchReceiver:suspendHandler:)"))); ++ (void)Skie_Suspend__16__advanceStaleWatermarkDispatchReceiver:(id)dispatchReceiver namespace:(SCSStoreNamespace *)namespace_ suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__16__advanceStaleWatermark(dispatchReceiver:namespace:suspendHandler:)"))); ++ (void)Skie_Suspend__17__forgetDispatchReceiver:(id)dispatchReceiver key:(id)key suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__17__forget(dispatchReceiver:key:suspendHandler:)"))); ++ (void)Skie_Suspend__18__forgetAllDispatchReceiver:(id)dispatchReceiver suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__18__forgetAll(dispatchReceiver:suspendHandler:)"))); ++ (void)Skie_Suspend__19__forgetNamespaceDispatchReceiver:(id)dispatchReceiver namespace:(SCSStoreNamespace *)namespace_ suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__19__forgetNamespace(dispatchReceiver:namespace:suspendHandler:)"))); ++ (void)Skie_Suspend__1__clearAllDispatchReceiver:(id)dispatchReceiver suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__1__clearAll(dispatchReceiver:suspendHandler:)"))); ++ (void)Skie_Suspend__20__markStaleDispatchReceiver:(id)dispatchReceiver key:(id)key suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__20__markStale(dispatchReceiver:key:suspendHandler:)"))); ++ (void)Skie_Suspend__21__recordFailureDispatchReceiver:(id)dispatchReceiver key:(id)key atEpochMillis:(int64_t)atEpochMillis suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__21__recordFailure(dispatchReceiver:key:atEpochMillis:suspendHandler:)"))); ++ (void)Skie_Suspend__22__recordSuccessDispatchReceiver:(id)dispatchReceiver key:(id)key meta:(id)meta suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__22__recordSuccess(dispatchReceiver:key:meta:suspendHandler:)"))); ++ (void)Skie_Suspend__23__statusDispatchReceiver:(id)dispatchReceiver key:(id)key suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__23__status(dispatchReceiver:key:suspendHandler:)"))); ++ (void)Skie_Suspend__24__applyDispatchReceiver:(id)dispatchReceiver key:(id)key value:(id)value suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__24__apply(dispatchReceiver:key:value:suspendHandler:)"))); ++ (void)Skie_Suspend__25__confirmFreshDispatchReceiver:(id)dispatchReceiver key:(id)key etag:(NSString * _Nullable)etag suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__25__confirmFresh(dispatchReceiver:key:etag:suspendHandler:)"))); ++ (void)Skie_Suspend__26__markStaleDispatchReceiver:(id)dispatchReceiver key:(id)key suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__26__markStale(dispatchReceiver:key:suspendHandler:)"))); ++ (void)Skie_Suspend__27__withTransactionDispatchReceiver:(id)dispatchReceiver block:(id)block suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__27__withTransaction(dispatchReceiver:block:suspendHandler:)"))); ++ (void)Skie_Suspend__28__invokeDispatchReceiver:(id)dispatchReceiver suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__28__invoke(dispatchReceiver:suspendHandler:)"))); ++ (void)Skie_Suspend__29__hasNextDispatchReceiver:(SCSSkieColdFlowIterator *)dispatchReceiver suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__29__hasNext(dispatchReceiver:suspendHandler:)"))); ++ (void)Skie_Suspend__2__clearNamespaceDispatchReceiver:(id)dispatchReceiver namespace:(SCSStoreNamespace *)namespace_ suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__2__clearNamespace(dispatchReceiver:namespace:suspendHandler:)"))); ++ (void)Skie_Suspend__3__getDispatchReceiver:(id)dispatchReceiver key:(id)key freshness:(id)freshness suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__3__get(dispatchReceiver:key:freshness:suspendHandler:)"))); ++ (void)Skie_Suspend__4__invalidateDispatchReceiver:(id)dispatchReceiver key:(id)key suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__4__invalidate(dispatchReceiver:key:suspendHandler:)"))); ++ (void)Skie_Suspend__5__invalidateAllDispatchReceiver:(id)dispatchReceiver suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__5__invalidateAll(dispatchReceiver:suspendHandler:)"))); ++ (void)Skie_Suspend__6__invalidateNamespaceDispatchReceiver:(id)dispatchReceiver namespace:(SCSStoreNamespace *)namespace_ suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__6__invalidateNamespace(dispatchReceiver:namespace:suspendHandler:)"))); ++ (void)Skie_Suspend__7__collectDispatchReceiver:(id)dispatchReceiver collector:(id)collector suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__7__collect(dispatchReceiver:collector:suspendHandler:)"))); ++ (void)Skie_Suspend__8__emitDispatchReceiver:(id)dispatchReceiver value:(id _Nullable)value suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__8__emit(dispatchReceiver:value:suspendHandler:)"))); ++ (void)Skie_Suspend__9__deleteDispatchReceiver:(id)dispatchReceiver key:(id)key suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__9__delete(dispatchReceiver:key:suspendHandler:)"))); +@end + +__attribute__((swift_name("KotlinIllegalStateException"))) +@interface SCSKotlinIllegalStateException : SCSKotlinRuntimeException +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); ++ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); +- (instancetype)initWithMessage:(NSString * _Nullable)message __attribute__((swift_name("init(message:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithCause:(SCSKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithMessage:(NSString * _Nullable)message cause:(SCSKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(message:cause:)"))) __attribute__((objc_designated_initializer)); +@end + + +/** + * @note annotations + * kotlin.SinceKotlin(version="1.4") +*/ +__attribute__((swift_name("KotlinCancellationException"))) +@interface SCSKotlinCancellationException : SCSKotlinIllegalStateException +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); ++ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); +- (instancetype)initWithMessage:(NSString * _Nullable)message __attribute__((swift_name("init(message:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithCause:(SCSKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithMessage:(NSString * _Nullable)message cause:(SCSKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(message:cause:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((swift_name("Kotlinx_coroutines_coreRunnable"))) +@protocol SCSKotlinx_coroutines_coreRunnable +@required +- (void)run __attribute__((swift_name("run()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("KotlinEnumCompanion"))) +@interface SCSKotlinEnumCompanion : SCSBase +@property (class, readonly, getter=shared) SCSKotlinEnumCompanion *shared __attribute__((swift_name("shared"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)companion __attribute__((swift_name("init()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("KotlinArray"))) +@interface SCSKotlinArray : SCSBase +@property (readonly) int32_t size __attribute__((swift_name("size"))); ++ (instancetype)arrayWithSize:(int32_t)size init:(T _Nullable (^)(SCSInt *))init __attribute__((swift_name("init(size:init:)"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); +- (T _Nullable)getIndex:(int32_t)index __attribute__((swift_name("get(index:)"))); +- (id)iterator __attribute__((swift_name("iterator()"))); +- (void)setIndex:(int32_t)index value:(T _Nullable)value __attribute__((swift_name("set(index:value:)"))); +@end + +__attribute__((swift_name("KotlinFunction"))) +@protocol SCSKotlinFunction +@required +@end + +__attribute__((swift_name("KotlinSuspendFunction1"))) +@protocol SCSKotlinSuspendFunction1 +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)invokeP1:(id _Nullable)p1 completionHandler:(void (^)(id _Nullable_result, NSError * _Nullable))completionHandler __attribute__((swift_name("invoke(p1:completionHandler:)"))); +@end + +__attribute__((swift_name("KotlinSuspendFunction0"))) +@protocol SCSKotlinSuspendFunction0 +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)invokeWithCompletionHandler:(void (^)(id _Nullable_result, NSError * _Nullable))completionHandler __attribute__((swift_name("invoke(completionHandler:)"))); +@end + +__attribute__((swift_name("KotlinIterator"))) +@protocol SCSKotlinIterator +@required +- (BOOL)hasNext __attribute__((swift_name("hasNext()"))); +- (id _Nullable)next __attribute__((swift_name("next()"))); +@end + +#pragma pop_macro("_Nullable_result") +#pragma clang diagnostic pop +NS_ASSUME_NONNULL_END diff --git a/store6-core/api/swift/skie/Store6CoreSkie.swift b/store6-core/api/swift/skie/Store6CoreSkie.swift new file mode 100644 index 000000000..6c8bc264b --- /dev/null +++ b/store6-core/api/swift/skie/Store6CoreSkie.swift @@ -0,0 +1,2895 @@ +// FILE: KotlinxCoroutinesCore/KotlinxCoroutinesCore.Flow.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6CoreSkie.Kotlinx_coroutines_coreFlow { + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func collect(collector: Store6CoreSkie.Kotlinx_coroutines_coreFlowCollector) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__7__collect(dispatchReceiver: self, collector: collector, suspendHandler: $0) + } + } + +} + +// FILE: KotlinxCoroutinesCore/KotlinxCoroutinesCore.FlowCollector.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6CoreSkie.Kotlinx_coroutines_coreFlowCollector { + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func emit(value: Any?) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__8__emit(dispatchReceiver: self, value: value, suspendHandler: $0) + } + } + +} + +// FILE: Skie/Skie.AsyncStreamDispatcherDelegate.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +class AsyncStreamDispatcherDelegate: Skie.co_touchlab_skie__runtime_kotlin.Skie_DispatcherDelegate.__Kotlin { + + private let continuation: _Concurrency.AsyncStream.Continuation + + init(continuation: _Concurrency.AsyncStream.Continuation) { + self.continuation = continuation + } + + func dispatch(block: Skie.org_jetbrains_kotlinx__kotlinx_coroutines_core.Runnable.__Kotlin) { + let result = continuation.yield(block) + + if case .terminated = result { + Swift.fatalError("Cannot dispatch blocks after the dispatcher is stopped. This error might have happened by leaking the dispatcher from the original job.") + } + } + + func stop() { + continuation.finish() + } +} + +// FILE: Skie/Skie.FlowConversions.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public func SkieKotlinFlow(_ flow: Store6CoreSkie.SkieSwiftFlow) -> Store6CoreSkie.SkieKotlinFlow { + return Store6CoreSkie.SkieKotlinFlow(flow.delegate) +} + +public func SkieKotlinFlow(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) -> Store6CoreSkie.SkieKotlinFlow { + return Store6CoreSkie.SkieKotlinFlow(flow.delegate) +} + +public func SkieKotlinFlow(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) -> Store6CoreSkie.SkieKotlinFlow { + return Store6CoreSkie.SkieKotlinFlow(flow.delegate) +} + +public func SkieKotlinFlow(_ flow: Store6CoreSkie.SkieSwiftStateFlow) -> Store6CoreSkie.SkieKotlinFlow { + return Store6CoreSkie.SkieKotlinFlow(flow.delegate) +} + +public func SkieKotlinFlow(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) -> Store6CoreSkie.SkieKotlinFlow { + return Store6CoreSkie.SkieKotlinFlow(flow.delegate) +} + +public func SkieKotlinFlow(_ flow: Store6CoreSkie.SkieSwiftFlow) -> Store6CoreSkie.SkieKotlinFlow { + return Store6CoreSkie.SkieKotlinFlow(flow.delegate) +} + +public func SkieKotlinFlow(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) -> Store6CoreSkie.SkieKotlinFlow { + return Store6CoreSkie.SkieKotlinFlow(flow.delegate) +} + +public func SkieKotlinFlow(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) -> Store6CoreSkie.SkieKotlinFlow { + return Store6CoreSkie.SkieKotlinFlow(flow.delegate) +} + +public func SkieKotlinFlow(_ flow: Store6CoreSkie.SkieSwiftStateFlow) -> Store6CoreSkie.SkieKotlinFlow { + return Store6CoreSkie.SkieKotlinFlow(flow.delegate) +} + +public func SkieKotlinFlow(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) -> Store6CoreSkie.SkieKotlinFlow { + return Store6CoreSkie.SkieKotlinFlow(flow.delegate) +} + +extension Store6CoreSkie.SkieSwiftFlow where T : Swift.AnyObject { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +extension Store6CoreSkie.SkieSwiftFlow where T : Swift._ObjectiveCBridgeable { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftStateFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalStateFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftStateFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalStateFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +extension Store6CoreSkie.SkieSwiftOptionalFlow where T : Swift.AnyObject { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +extension Store6CoreSkie.SkieSwiftOptionalFlow where T : Swift._ObjectiveCBridgeable { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +public func SkieKotlinSharedFlow(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) -> Store6CoreSkie.SkieKotlinSharedFlow { + return Store6CoreSkie.SkieKotlinSharedFlow(flow.delegate) +} + +public func SkieKotlinSharedFlow(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) -> Store6CoreSkie.SkieKotlinSharedFlow { + return Store6CoreSkie.SkieKotlinSharedFlow(flow.delegate) +} + +public func SkieKotlinSharedFlow(_ flow: Store6CoreSkie.SkieSwiftStateFlow) -> Store6CoreSkie.SkieKotlinSharedFlow { + return Store6CoreSkie.SkieKotlinSharedFlow(flow.delegate) +} + +public func SkieKotlinSharedFlow(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) -> Store6CoreSkie.SkieKotlinSharedFlow { + return Store6CoreSkie.SkieKotlinSharedFlow(flow.delegate) +} + +public func SkieKotlinSharedFlow(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) -> Store6CoreSkie.SkieKotlinSharedFlow { + return Store6CoreSkie.SkieKotlinSharedFlow(flow.delegate) +} + +public func SkieKotlinSharedFlow(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) -> Store6CoreSkie.SkieKotlinSharedFlow { + return Store6CoreSkie.SkieKotlinSharedFlow(flow.delegate) +} + +public func SkieKotlinSharedFlow(_ flow: Store6CoreSkie.SkieSwiftStateFlow) -> Store6CoreSkie.SkieKotlinSharedFlow { + return Store6CoreSkie.SkieKotlinSharedFlow(flow.delegate) +} + +public func SkieKotlinSharedFlow(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) -> Store6CoreSkie.SkieKotlinSharedFlow { + return Store6CoreSkie.SkieKotlinSharedFlow(flow.delegate) +} + +extension Store6CoreSkie.SkieSwiftSharedFlow where T : Swift.AnyObject { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +extension Store6CoreSkie.SkieSwiftSharedFlow where T : Swift._ObjectiveCBridgeable { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6CoreSkie.SkieSwiftStateFlow) -> Store6CoreSkie.SkieKotlinOptionalSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalStateFlow) -> Store6CoreSkie.SkieKotlinOptionalSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) -> Store6CoreSkie.SkieKotlinOptionalSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) -> Store6CoreSkie.SkieKotlinOptionalSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6CoreSkie.SkieSwiftStateFlow) -> Store6CoreSkie.SkieKotlinOptionalSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalStateFlow) -> Store6CoreSkie.SkieKotlinOptionalSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) -> Store6CoreSkie.SkieKotlinOptionalSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) -> Store6CoreSkie.SkieKotlinOptionalSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +extension Store6CoreSkie.SkieSwiftOptionalSharedFlow where T : Swift.AnyObject { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +extension Store6CoreSkie.SkieSwiftOptionalSharedFlow where T : Swift._ObjectiveCBridgeable { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +public func SkieKotlinMutableSharedFlow(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) -> Store6CoreSkie.SkieKotlinMutableSharedFlow { + return Store6CoreSkie.SkieKotlinMutableSharedFlow(flow.delegate) +} + +public func SkieKotlinMutableSharedFlow(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) -> Store6CoreSkie.SkieKotlinMutableSharedFlow { + return Store6CoreSkie.SkieKotlinMutableSharedFlow(flow.delegate) +} + +extension Store6CoreSkie.SkieSwiftMutableSharedFlow where T : Swift.AnyObject { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + +} + +extension Store6CoreSkie.SkieSwiftMutableSharedFlow where T : Swift._ObjectiveCBridgeable { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + +} + +public func SkieKotlinOptionalMutableSharedFlow(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalMutableSharedFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalMutableSharedFlow(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalMutableSharedFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow(flow.delegate) +} + +extension Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow where T : Swift.AnyObject { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow) { + self.init(internal: flow.delegate) + } + +} + +extension Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow where T : Swift._ObjectiveCBridgeable { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow) { + self.init(internal: flow.delegate) + } + +} + +public func SkieKotlinStateFlow(_ flow: Store6CoreSkie.SkieSwiftStateFlow) -> Store6CoreSkie.SkieKotlinStateFlow { + return Store6CoreSkie.SkieKotlinStateFlow(flow.delegate) +} + +public func SkieKotlinStateFlow(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) -> Store6CoreSkie.SkieKotlinStateFlow { + return Store6CoreSkie.SkieKotlinStateFlow(flow.delegate) +} + +public func SkieKotlinStateFlow(_ flow: Store6CoreSkie.SkieSwiftStateFlow) -> Store6CoreSkie.SkieKotlinStateFlow { + return Store6CoreSkie.SkieKotlinStateFlow(flow.delegate) +} + +public func SkieKotlinStateFlow(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) -> Store6CoreSkie.SkieKotlinStateFlow { + return Store6CoreSkie.SkieKotlinStateFlow(flow.delegate) +} + +extension Store6CoreSkie.SkieSwiftStateFlow where T : Swift.AnyObject { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +extension Store6CoreSkie.SkieSwiftStateFlow where T : Swift._ObjectiveCBridgeable { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +public func SkieKotlinOptionalStateFlow(_ flow: Store6CoreSkie.SkieSwiftStateFlow) -> Store6CoreSkie.SkieKotlinOptionalStateFlow { + return Store6CoreSkie.SkieKotlinOptionalStateFlow(flow.delegate) +} + +public func SkieKotlinOptionalStateFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalStateFlow) -> Store6CoreSkie.SkieKotlinOptionalStateFlow { + return Store6CoreSkie.SkieKotlinOptionalStateFlow(flow.delegate) +} + +public func SkieKotlinOptionalStateFlow(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) -> Store6CoreSkie.SkieKotlinOptionalStateFlow { + return Store6CoreSkie.SkieKotlinOptionalStateFlow(flow.delegate) +} + +public func SkieKotlinOptionalStateFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) -> Store6CoreSkie.SkieKotlinOptionalStateFlow { + return Store6CoreSkie.SkieKotlinOptionalStateFlow(flow.delegate) +} + +public func SkieKotlinOptionalStateFlow(_ flow: Store6CoreSkie.SkieSwiftStateFlow) -> Store6CoreSkie.SkieKotlinOptionalStateFlow { + return Store6CoreSkie.SkieKotlinOptionalStateFlow(flow.delegate) +} + +public func SkieKotlinOptionalStateFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalStateFlow) -> Store6CoreSkie.SkieKotlinOptionalStateFlow { + return Store6CoreSkie.SkieKotlinOptionalStateFlow(flow.delegate) +} + +public func SkieKotlinOptionalStateFlow(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) -> Store6CoreSkie.SkieKotlinOptionalStateFlow { + return Store6CoreSkie.SkieKotlinOptionalStateFlow(flow.delegate) +} + +public func SkieKotlinOptionalStateFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) -> Store6CoreSkie.SkieKotlinOptionalStateFlow { + return Store6CoreSkie.SkieKotlinOptionalStateFlow(flow.delegate) +} + +extension Store6CoreSkie.SkieSwiftOptionalStateFlow where T : Swift.AnyObject { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +extension Store6CoreSkie.SkieSwiftOptionalStateFlow where T : Swift._ObjectiveCBridgeable { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +public func SkieKotlinMutableStateFlow(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) -> Store6CoreSkie.SkieKotlinMutableStateFlow { + return Store6CoreSkie.SkieKotlinMutableStateFlow(flow.delegate) +} + +public func SkieKotlinMutableStateFlow(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) -> Store6CoreSkie.SkieKotlinMutableStateFlow { + return Store6CoreSkie.SkieKotlinMutableStateFlow(flow.delegate) +} + +extension Store6CoreSkie.SkieSwiftMutableStateFlow where T : Swift.AnyObject { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +extension Store6CoreSkie.SkieSwiftMutableStateFlow where T : Swift._ObjectiveCBridgeable { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +public func SkieKotlinOptionalMutableStateFlow(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) -> Store6CoreSkie.SkieKotlinOptionalMutableStateFlow { + return Store6CoreSkie.SkieKotlinOptionalMutableStateFlow(flow.delegate) +} + +public func SkieKotlinOptionalMutableStateFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) -> Store6CoreSkie.SkieKotlinOptionalMutableStateFlow { + return Store6CoreSkie.SkieKotlinOptionalMutableStateFlow(flow.delegate) +} + +public func SkieKotlinOptionalMutableStateFlow(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) -> Store6CoreSkie.SkieKotlinOptionalMutableStateFlow { + return Store6CoreSkie.SkieKotlinOptionalMutableStateFlow(flow.delegate) +} + +public func SkieKotlinOptionalMutableStateFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) -> Store6CoreSkie.SkieKotlinOptionalMutableStateFlow { + return Store6CoreSkie.SkieKotlinOptionalMutableStateFlow(flow.delegate) +} + +extension Store6CoreSkie.SkieSwiftOptionalMutableStateFlow where T : Swift.AnyObject { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +extension Store6CoreSkie.SkieSwiftOptionalMutableStateFlow where T : Swift._ObjectiveCBridgeable { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +// FILE: Skie/Skie.Namespace.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public enum Skie { + + public enum KotlinxCoroutinesCore { + + public enum Flow { + + public typealias __Kotlin = Store6CoreSkie.Kotlinx_coroutines_coreFlow + + } + + public enum StateFlow { + + public typealias __Kotlin = Store6CoreSkie.Kotlinx_coroutines_coreStateFlow + + } + + public enum SharedFlow { + + public typealias __Kotlin = Store6CoreSkie.Kotlinx_coroutines_coreSharedFlow + + } + + public enum MutableSharedFlow { + + public typealias __Kotlin = Store6CoreSkie.Kotlinx_coroutines_coreMutableSharedFlow + + } + + public enum MutableStateFlow { + + public typealias __Kotlin = Store6CoreSkie.Kotlinx_coroutines_coreMutableStateFlow + + } + + public enum Runnable { + + public typealias __Kotlin = Store6CoreSkie.Kotlinx_coroutines_coreRunnable + + } + + } + + public typealias org_jetbrains_kotlinx__kotlinx_coroutines_core = Store6CoreSkie.Skie.KotlinxCoroutinesCore + + public enum RuntimeKotlin { + + public enum SkieColdFlowIterator { + + public typealias __Kotlin = Store6CoreSkie.SkieColdFlowIterator + + } + + public enum SkieKotlinFlow { + + public typealias __Kotlin = Store6CoreSkie.SkieKotlinFlow + + } + + public enum SkieKotlinMutableSharedFlow { + + public typealias __Kotlin = Store6CoreSkie.SkieKotlinMutableSharedFlow + + } + + public enum SkieKotlinMutableStateFlow { + + public typealias __Kotlin = Store6CoreSkie.SkieKotlinMutableStateFlow + + } + + public enum SkieKotlinOptionalFlow { + + public typealias __Kotlin = Store6CoreSkie.SkieKotlinOptionalFlow + + } + + public enum SkieKotlinOptionalMutableSharedFlow { + + public typealias __Kotlin = Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow + + } + + public enum SkieKotlinOptionalMutableStateFlow { + + public typealias __Kotlin = Store6CoreSkie.SkieKotlinOptionalMutableStateFlow + + } + + public enum SkieKotlinOptionalSharedFlow { + + public typealias __Kotlin = Store6CoreSkie.SkieKotlinOptionalSharedFlow + + } + + public enum SkieKotlinOptionalStateFlow { + + public typealias __Kotlin = Store6CoreSkie.SkieKotlinOptionalStateFlow + + } + + public enum SkieKotlinSharedFlow { + + public typealias __Kotlin = Store6CoreSkie.SkieKotlinSharedFlow + + } + + public enum SkieKotlinStateFlow { + + public typealias __Kotlin = Store6CoreSkie.SkieKotlinStateFlow + + } + + public enum Skie_CancellationHandler { + + public typealias __Kotlin = Store6CoreSkie.Skie_CancellationHandler + + } + + public enum Skie_DispatcherDelegate { + + public typealias __Kotlin = Store6CoreSkie.Skie_DispatcherDelegate + + } + + public enum Skie_SuspendHandler { + + public typealias __Kotlin = Store6CoreSkie.Skie_SuspendHandler + + } + + public enum Skie_SuspendResult { + + public typealias __Kotlin = Store6CoreSkie.Skie_SuspendResult + + public enum Success { + + public typealias __Kotlin = Store6CoreSkie.Skie_SuspendResult.Success + + } + + public enum Error { + + public typealias __Kotlin = Store6CoreSkie.Skie_SuspendResult.Error + + } + + public enum Canceled { + + public typealias __Kotlin = Store6CoreSkie.Skie_SuspendResult.Canceled + + } + + } + + } + + public typealias co_touchlab_skie__runtime_kotlin = Store6CoreSkie.Skie.RuntimeKotlin + + public enum Store6Core { + + public enum Freshness { + + } + + public enum StoreError { + + } + + public enum FetchPlan { + + } + + public enum FetcherResult { + + } + + public enum StoreResult { + + } + + } + + public typealias Store5__store6_core = Store6CoreSkie.Skie.Store6Core + +} + +// FILE: Skie/Skie.SkieColdFlowIterator.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6CoreSkie.Skie.RuntimeKotlin.SkieColdFlowIterator { + + public struct __Suspend { + + public let __kotlinObject: Store6CoreSkie.SkieColdFlowIterator + + public init(_ __kotlinObject: Store6CoreSkie.SkieColdFlowIterator) { + self.__kotlinObject = __kotlinObject + } + + } + +} + +public func skie(_ kotlinObject: Store6CoreSkie.SkieColdFlowIterator) -> Store6CoreSkie.Skie.RuntimeKotlin.SkieColdFlowIterator.__Suspend { + return Store6CoreSkie.Skie.RuntimeKotlin.SkieColdFlowIterator.__Suspend(kotlinObject) +} + +extension Store6CoreSkie.Skie.RuntimeKotlin.SkieColdFlowIterator.__Suspend { + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func hasNext() async throws -> Store6CoreSkie.KotlinBoolean { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__29__hasNext(dispatchReceiver: __kotlinObject as! Store6CoreSkie.SkieColdFlowIterator, suspendHandler: $0) + } + } + +} + +// FILE: Skie/Skie.SkieSwiftFlow.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public final class SkieSwiftFlow : Store6CoreSkie.SkieSwiftFlowProtocol, + Swift._ObjectiveCBridgeable { + + @_spi(SKIE) + public let delegate: Store6CoreSkie.Kotlinx_coroutines_coreFlow + + init(`internal` flow: Store6CoreSkie.Kotlinx_coroutines_coreFlow) { + delegate = flow + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinFlow, result: inout Store6CoreSkie.SkieSwiftFlow?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinFlow, result: inout Store6CoreSkie.SkieSwiftFlow?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinFlow?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6CoreSkie.SkieKotlinFlow { + return Store6CoreSkie.SkieKotlinFlow(delegate) + } + + private static func fromObjectiveC(_ source: Store6CoreSkie.SkieKotlinFlow?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6CoreSkie.SkieSwiftFlow") + } + return .init(internal: source) + } + + public func makeAsyncIterator() -> Store6CoreSkie.SkieSwiftFlowIterator { + return SkieSwiftFlowIterator(flow: delegate) + } + + public typealias AsyncIterator = Store6CoreSkie.SkieSwiftFlowIterator + + public typealias Element = T + + public typealias _ObjectiveCType = Store6CoreSkie.SkieKotlinFlow + +} + +// FILE: Skie/Skie.SkieSwiftFlowIterator.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation +import _Concurrency + +public class SkieSwiftFlowIterator : _Concurrency.AsyncIteratorProtocol { + + private let iterator: Store6CoreSkie.SkieColdFlowIterator + + init(flow: Store6CoreSkie.Kotlinx_coroutines_coreFlow) { + iterator = .init(flow: flow) + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func next() async -> T? { + do { + let hasNext = try await skie(iterator).hasNext() + + if hasNext.boolValue { + return .some(iterator.next() as! Element) + } else { + return nil + } + } catch is _Concurrency.CancellationError { + await cancelTask() + + return nil + } catch { + Swift.fatalError("Unexpected error: \(error)") + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + private func cancelTask() async -> Swift.Void { + _Concurrency.withUnsafeCurrentTask { task in + task?.cancel() + } + } + + deinit { + iterator.cancel() + } + + public typealias Element = T + +} + +// FILE: Skie/Skie.SkieSwiftFlowProtocol.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation +import _Concurrency + +public protocol SkieSwiftFlowProtocol : _Concurrency.AsyncSequence { + + associatedtype Element + associatedtype Delegate : Store6CoreSkie.Kotlinx_coroutines_coreFlow + + @_spi(SKIE) + var delegate: Delegate { get } + +} + +extension Store6CoreSkie.SkieSwiftFlowProtocol { + + var delegate: Delegate { + Swift.fatalError("SkieSwiftFlowProtocol has to be conformed to with @_spi(SKIE) enabled and property 'delegate' implemented") + } + +} + +// FILE: Skie/Skie.SkieSwiftMutableSharedFlow.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public final class SkieSwiftMutableSharedFlow : Store6CoreSkie.SkieSwiftFlowProtocol, + Swift._ObjectiveCBridgeable { + + @_spi(SKIE) + public let delegate: Store6CoreSkie.Kotlinx_coroutines_coreMutableSharedFlow + + public var subscriptionCount: Store6CoreSkie.SkieSwiftStateFlow { + bridgeSubscriptionCount(delegate.subscriptionCount) + } + + public var replayCache: [T] { + delegate.replayCache as! [T] + } + + init(`internal` flow: Store6CoreSkie.Kotlinx_coroutines_coreMutableSharedFlow) { + delegate = flow + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func emit(value: T) async throws -> Swift.Void { + try await delegate.emit(value: value) + } + + public func tryEmit(value: T) -> Swift.Bool { + delegate.tryEmit(value: value) + } + + public func resetReplayCache() -> Swift.Void { + delegate.resetReplayCache() + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinMutableSharedFlow, result: inout Store6CoreSkie.SkieSwiftMutableSharedFlow?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinMutableSharedFlow, result: inout Store6CoreSkie.SkieSwiftMutableSharedFlow?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinMutableSharedFlow?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6CoreSkie.SkieKotlinMutableSharedFlow { + return Store6CoreSkie.SkieKotlinMutableSharedFlow(delegate) + } + + private static func fromObjectiveC(_ source: Store6CoreSkie.SkieKotlinMutableSharedFlow?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6CoreSkie.SkieSwiftMutableSharedFlow") + } + return .init(internal: source) + } + + public func makeAsyncIterator() -> Store6CoreSkie.SkieSwiftFlowIterator { + return SkieSwiftFlowIterator(flow: delegate) + } + + public typealias AsyncIterator = Store6CoreSkie.SkieSwiftFlowIterator + + public typealias Element = T + + public typealias _ObjectiveCType = Store6CoreSkie.SkieKotlinMutableSharedFlow + +} + +// FILE: Skie/Skie.SkieSwiftMutableStateFlow.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public final class SkieSwiftMutableStateFlow : Store6CoreSkie.SkieSwiftFlowProtocol, + Swift._ObjectiveCBridgeable { + + @_spi(SKIE) + public let delegate: Store6CoreSkie.Kotlinx_coroutines_coreMutableStateFlow + + public var value: T { + get { + delegate.value as! T + } + set(value) { + delegate.setValue(value) + } + } + + public var replayCache: [T] { + delegate.replayCache as! [T] + } + + public var subscriptionCount: Store6CoreSkie.SkieSwiftStateFlow { + bridgeSubscriptionCount(delegate.subscriptionCount) + } + + init(`internal` flow: Store6CoreSkie.Kotlinx_coroutines_coreMutableStateFlow) { + delegate = flow + } + + public func compareAndSet(expect: T, update: T) -> Swift.Bool { + delegate.compareAndSet(expect: expect, update: update) + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func emit(value: T) async throws -> Swift.Void { + try await delegate.emit(value: value) + } + + public func tryEmit(value: T) -> Swift.Bool { + delegate.tryEmit(value: value) + } + + public func resetReplayCache() -> Swift.Void { + delegate.resetReplayCache() + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinMutableStateFlow, result: inout Store6CoreSkie.SkieSwiftMutableStateFlow?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinMutableStateFlow, result: inout Store6CoreSkie.SkieSwiftMutableStateFlow?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinMutableStateFlow?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6CoreSkie.SkieKotlinMutableStateFlow { + return Store6CoreSkie.SkieKotlinMutableStateFlow(delegate) + } + + private static func fromObjectiveC(_ source: Store6CoreSkie.SkieKotlinMutableStateFlow?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6CoreSkie.SkieSwiftMutableStateFlow") + } + return .init(internal: source) + } + + public func makeAsyncIterator() -> Store6CoreSkie.SkieSwiftFlowIterator { + return SkieSwiftFlowIterator(flow: delegate) + } + + public typealias AsyncIterator = Store6CoreSkie.SkieSwiftFlowIterator + + public typealias Element = T + + public typealias _ObjectiveCType = Store6CoreSkie.SkieKotlinMutableStateFlow + +} + +// FILE: Skie/Skie.SkieSwiftOptionalFlow.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public final class SkieSwiftOptionalFlow : Store6CoreSkie.SkieSwiftFlowProtocol, + Swift._ObjectiveCBridgeable { + + @_spi(SKIE) + public let delegate: Store6CoreSkie.Kotlinx_coroutines_coreFlow + + init(`internal` flow: Store6CoreSkie.Kotlinx_coroutines_coreFlow) { + delegate = flow + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalFlow, result: inout Store6CoreSkie.SkieSwiftOptionalFlow?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalFlow, result: inout Store6CoreSkie.SkieSwiftOptionalFlow?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalFlow?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(delegate) + } + + private static func fromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalFlow?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6CoreSkie.SkieSwiftOptionalFlow") + } + return .init(internal: source) + } + + public func makeAsyncIterator() -> Store6CoreSkie.SkieSwiftFlowIterator { + return SkieSwiftFlowIterator(flow: delegate) + } + + public typealias AsyncIterator = Store6CoreSkie.SkieSwiftFlowIterator + + public typealias Element = T? + + public typealias _ObjectiveCType = Store6CoreSkie.SkieKotlinOptionalFlow + +} + +// FILE: Skie/Skie.SkieSwiftOptionalMutableSharedFlow.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public final class SkieSwiftOptionalMutableSharedFlow : Store6CoreSkie.SkieSwiftFlowProtocol, + Swift._ObjectiveCBridgeable { + + @_spi(SKIE) + public let delegate: Store6CoreSkie.Kotlinx_coroutines_coreMutableSharedFlow + + public var subscriptionCount: Store6CoreSkie.SkieSwiftStateFlow { + bridgeSubscriptionCount(delegate.subscriptionCount) + } + + public var replayCache: [T?] { + delegate.replayCache as! [T?] + } + + init(`internal` flow: Store6CoreSkie.Kotlinx_coroutines_coreMutableSharedFlow) { + delegate = flow + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func emit(value: T?) async throws -> Swift.Void { + try await delegate.emit(value: value) + } + + public func tryEmit(value: T?) -> Swift.Bool { + delegate.tryEmit(value: value) + } + + public func resetReplayCache() -> Swift.Void { + delegate.resetReplayCache() + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow, result: inout Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow, result: inout Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow(delegate) + } + + private static func fromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow") + } + return .init(internal: source) + } + + public func makeAsyncIterator() -> Store6CoreSkie.SkieSwiftFlowIterator { + return SkieSwiftFlowIterator(flow: delegate) + } + + public typealias AsyncIterator = Store6CoreSkie.SkieSwiftFlowIterator + + public typealias Element = T? + + public typealias _ObjectiveCType = Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow + +} + +// FILE: Skie/Skie.SkieSwiftOptionalMutableStateFlow.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public final class SkieSwiftOptionalMutableStateFlow : Store6CoreSkie.SkieSwiftFlowProtocol, + Swift._ObjectiveCBridgeable { + + @_spi(SKIE) + public let delegate: Store6CoreSkie.Kotlinx_coroutines_coreMutableStateFlow + + public var value: T? { + get { + delegate.value as! T? + } + set(value) { + delegate.setValue(value) + } + } + + public var replayCache: [T?] { + delegate.replayCache as! [T?] + } + + public var subscriptionCount: Store6CoreSkie.SkieSwiftStateFlow { + bridgeSubscriptionCount(delegate.subscriptionCount) + } + + init(`internal` flow: Store6CoreSkie.Kotlinx_coroutines_coreMutableStateFlow) { + delegate = flow + } + + public func compareAndSet(expect: T?, update: T?) -> Swift.Bool { + delegate.compareAndSet(expect: expect, update: update) + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func emit(value: T?) async throws -> Swift.Void { + try await delegate.emit(value: value) + } + + public func tryEmit(value: T?) -> Swift.Bool { + delegate.tryEmit(value: value) + } + + public func resetReplayCache() -> Swift.Void { + delegate.resetReplayCache() + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalMutableStateFlow, result: inout Store6CoreSkie.SkieSwiftOptionalMutableStateFlow?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalMutableStateFlow, result: inout Store6CoreSkie.SkieSwiftOptionalMutableStateFlow?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalMutableStateFlow?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6CoreSkie.SkieKotlinOptionalMutableStateFlow { + return Store6CoreSkie.SkieKotlinOptionalMutableStateFlow(delegate) + } + + private static func fromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalMutableStateFlow?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6CoreSkie.SkieSwiftOptionalMutableStateFlow") + } + return .init(internal: source) + } + + public func makeAsyncIterator() -> Store6CoreSkie.SkieSwiftFlowIterator { + return SkieSwiftFlowIterator(flow: delegate) + } + + public typealias AsyncIterator = Store6CoreSkie.SkieSwiftFlowIterator + + public typealias Element = T? + + public typealias _ObjectiveCType = Store6CoreSkie.SkieKotlinOptionalMutableStateFlow + +} + +// FILE: Skie/Skie.SkieSwiftOptionalSharedFlow.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public final class SkieSwiftOptionalSharedFlow : Store6CoreSkie.SkieSwiftFlowProtocol, + Swift._ObjectiveCBridgeable { + + @_spi(SKIE) + public let delegate: Store6CoreSkie.Kotlinx_coroutines_coreSharedFlow + + public var replayCache: [T?] { + delegate.replayCache as! [T?] + } + + init(`internal` flow: Store6CoreSkie.Kotlinx_coroutines_coreSharedFlow) { + delegate = flow + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalSharedFlow, result: inout Store6CoreSkie.SkieSwiftOptionalSharedFlow?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalSharedFlow, result: inout Store6CoreSkie.SkieSwiftOptionalSharedFlow?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalSharedFlow?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6CoreSkie.SkieKotlinOptionalSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalSharedFlow(delegate) + } + + private static func fromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalSharedFlow?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6CoreSkie.SkieSwiftOptionalSharedFlow") + } + return .init(internal: source) + } + + public func makeAsyncIterator() -> Store6CoreSkie.SkieSwiftFlowIterator { + return SkieSwiftFlowIterator(flow: delegate) + } + + public typealias AsyncIterator = Store6CoreSkie.SkieSwiftFlowIterator + + public typealias Element = T? + + public typealias _ObjectiveCType = Store6CoreSkie.SkieKotlinOptionalSharedFlow + +} + +// FILE: Skie/Skie.SkieSwiftOptionalStateFlow.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public final class SkieSwiftOptionalStateFlow : Store6CoreSkie.SkieSwiftFlowProtocol, + Swift._ObjectiveCBridgeable { + + @_spi(SKIE) + public let delegate: Store6CoreSkie.Kotlinx_coroutines_coreStateFlow + + public var value: T? { + delegate.value as! T? + } + + public var replayCache: [T?] { + delegate.replayCache as! [T?] + } + + init(`internal` flow: Store6CoreSkie.Kotlinx_coroutines_coreStateFlow) { + delegate = flow + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalStateFlow, result: inout Store6CoreSkie.SkieSwiftOptionalStateFlow?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalStateFlow, result: inout Store6CoreSkie.SkieSwiftOptionalStateFlow?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalStateFlow?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6CoreSkie.SkieKotlinOptionalStateFlow { + return Store6CoreSkie.SkieKotlinOptionalStateFlow(delegate) + } + + private static func fromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalStateFlow?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6CoreSkie.SkieSwiftOptionalStateFlow") + } + return .init(internal: source) + } + + public func makeAsyncIterator() -> Store6CoreSkie.SkieSwiftFlowIterator { + return SkieSwiftFlowIterator(flow: delegate) + } + + public typealias AsyncIterator = Store6CoreSkie.SkieSwiftFlowIterator + + public typealias Element = T? + + public typealias _ObjectiveCType = Store6CoreSkie.SkieKotlinOptionalStateFlow + +} + +// FILE: Skie/Skie.SkieSwiftSharedFlow.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public final class SkieSwiftSharedFlow : Store6CoreSkie.SkieSwiftFlowProtocol, + Swift._ObjectiveCBridgeable { + + @_spi(SKIE) + public let delegate: Store6CoreSkie.Kotlinx_coroutines_coreSharedFlow + + public var replayCache: [T] { + delegate.replayCache as! [T] + } + + init(`internal` flow: Store6CoreSkie.Kotlinx_coroutines_coreSharedFlow) { + delegate = flow + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinSharedFlow, result: inout Store6CoreSkie.SkieSwiftSharedFlow?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinSharedFlow, result: inout Store6CoreSkie.SkieSwiftSharedFlow?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinSharedFlow?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6CoreSkie.SkieKotlinSharedFlow { + return Store6CoreSkie.SkieKotlinSharedFlow(delegate) + } + + private static func fromObjectiveC(_ source: Store6CoreSkie.SkieKotlinSharedFlow?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6CoreSkie.SkieSwiftSharedFlow") + } + return .init(internal: source) + } + + public func makeAsyncIterator() -> Store6CoreSkie.SkieSwiftFlowIterator { + return SkieSwiftFlowIterator(flow: delegate) + } + + public typealias AsyncIterator = Store6CoreSkie.SkieSwiftFlowIterator + + public typealias Element = T + + public typealias _ObjectiveCType = Store6CoreSkie.SkieKotlinSharedFlow + +} + +// FILE: Skie/Skie.SkieSwiftStateFlow.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public final class SkieSwiftStateFlow : Store6CoreSkie.SkieSwiftFlowProtocol, + Swift._ObjectiveCBridgeable { + + @_spi(SKIE) + public let delegate: Store6CoreSkie.Kotlinx_coroutines_coreStateFlow + + public var value: T { + delegate.value as! T + } + + public var replayCache: [T] { + delegate.replayCache as! [T] + } + + init(`internal` flow: Store6CoreSkie.Kotlinx_coroutines_coreStateFlow) { + delegate = flow + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinStateFlow, result: inout Store6CoreSkie.SkieSwiftStateFlow?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinStateFlow, result: inout Store6CoreSkie.SkieSwiftStateFlow?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinStateFlow?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6CoreSkie.SkieKotlinStateFlow { + return Store6CoreSkie.SkieKotlinStateFlow(delegate) + } + + private static func fromObjectiveC(_ source: Store6CoreSkie.SkieKotlinStateFlow?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6CoreSkie.SkieSwiftStateFlow") + } + return .init(internal: source) + } + + public func makeAsyncIterator() -> Store6CoreSkie.SkieSwiftFlowIterator { + return SkieSwiftFlowIterator(flow: delegate) + } + + public typealias AsyncIterator = Store6CoreSkie.SkieSwiftFlowIterator + + public typealias Element = T + + public typealias _ObjectiveCType = Store6CoreSkie.SkieKotlinStateFlow + +} + +// FILE: Skie/Skie.Skie_SuspendResult.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6CoreSkie.Skie.RuntimeKotlin.Skie_SuspendResult { + + @frozen + public enum __Sealed : Swift.Hashable { + + case canceled(Store6CoreSkie.Skie_SuspendResult.Canceled) + case error(Store6CoreSkie.Skie_SuspendResult.Error) + case success(Store6CoreSkie.Skie_SuspendResult.Success) + + } + +} + +public func onEnum<__Sealed : Store6CoreSkie.Skie_SuspendResult>(of sealed: __Sealed) -> Store6CoreSkie.Skie.RuntimeKotlin.Skie_SuspendResult.__Sealed { + if let sealed = sealed as? Store6CoreSkie.Skie_SuspendResult.Canceled { + return Store6CoreSkie.Skie.RuntimeKotlin.Skie_SuspendResult.__Sealed.canceled(sealed) + } else if let sealed = sealed as? Store6CoreSkie.Skie_SuspendResult.Error { + return Store6CoreSkie.Skie.RuntimeKotlin.Skie_SuspendResult.__Sealed.error(sealed) + } else if let sealed = sealed as? Store6CoreSkie.Skie_SuspendResult.Success { + return Store6CoreSkie.Skie.RuntimeKotlin.Skie_SuspendResult.__Sealed.success(sealed) + } else { + fatalError("Unknown subtype \(sealed). This error should not happen under normal circumstances since SirClass: Store6CoreSkie.Skie_SuspendResult is sealed.") + } +} + +@_disfavoredOverload +public func onEnum<__Sealed : Store6CoreSkie.Skie_SuspendResult>(of sealed: __Sealed?) -> Store6CoreSkie.Skie.RuntimeKotlin.Skie_SuspendResult.__Sealed? { + if let sealed { + return onEnum(of: sealed) as Store6CoreSkie.Skie.RuntimeKotlin.Skie_SuspendResult.__Sealed + } else { + return nil + } +} + +// FILE: Skie/Skie.SwiftCoroutineDispatcher.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +struct SwiftCoroutineDispatcher { + + static func dispatch( + coroutine: (Skie.co_touchlab_skie__runtime_kotlin.Skie_SuspendHandler.__Kotlin) -> Swift.Void + ) async throws -> T { + let cancellationHandler = Skie.co_touchlab_skie__runtime_kotlin.Skie_CancellationHandler.__Kotlin() + + return try await _Concurrency.withTaskCancellationHandler(operation: { + try await dispatchCancellable(coroutine: coroutine, cancellationHandler: cancellationHandler) + }, onCancel: { + cancellationHandler.cancel() + }) + } + + private static func dispatchCancellable( + coroutine: (Skie.co_touchlab_skie__runtime_kotlin.Skie_SuspendHandler.__Kotlin) -> Swift.Void, + cancellationHandler: Skie.co_touchlab_skie__runtime_kotlin.Skie_CancellationHandler.__Kotlin + ) async throws -> T { + var result: Swift.Result? = nil + + let dispatcher = createDispatcher(coroutine: coroutine, cancellationHandler: cancellationHandler) { + result = $0 + } + + await executeWithoutCancellation(dispatcher: dispatcher) + + return try unwrap(result: result) + } + + private static func createDispatcher( + coroutine: (Skie.co_touchlab_skie__runtime_kotlin.Skie_SuspendHandler.__Kotlin) -> Swift.Void, + cancellationHandler: Skie.co_touchlab_skie__runtime_kotlin.Skie_CancellationHandler.__Kotlin, + onResult: @escaping (Swift.Result) -> Swift.Void + ) -> _Concurrency.AsyncStream { + return _Concurrency.AsyncStream { continuation in + let dispatcherDelegate = AsyncStreamDispatcherDelegate(continuation: continuation) + + let suspendHandler = Skie.co_touchlab_skie__runtime_kotlin.Skie_SuspendHandler.__Kotlin( + cancellationHandler: cancellationHandler, + dispatcherDelegate: dispatcherDelegate, + onResult: { suspendResult in + let result: Swift.Result = convertToResult(suspendResult: suspendResult) + + onResult(result) + + dispatcherDelegate.stop() + } + ) + + coroutine(suspendHandler) + } + } + + private static func convertToResult( + suspendResult: Skie.co_touchlab_skie__runtime_kotlin.Skie_SuspendResult.__Kotlin + ) -> Swift.Result { + if let suspendResult = suspendResult as? Skie.co_touchlab_skie__runtime_kotlin.Skie_SuspendResult.Success.__Kotlin { + if T.self == Swift.Void.self { + return .success(Swift.Void() as! T) + } else { + return .success(suspendResult.value as! T) + } + } else if let suspendResult = suspendResult as? Skie.co_touchlab_skie__runtime_kotlin.Skie_SuspendResult.Error.__Kotlin { + return .failure(suspendResult.error) + } else if suspendResult is Skie.co_touchlab_skie__runtime_kotlin.Skie_SuspendResult.Canceled.__Kotlin { + return .failure(_Concurrency.CancellationError()) + } else { + fatalError("Unknown suspend result. This is most likely a bug in SKIE.") + } + } + + private static func executeWithoutCancellation(dispatcher: _Concurrency.AsyncStream) async { + await _Concurrency.Task { + for await block in dispatcher { + block.run() + } + }.value + } + + private static func unwrap(result: Swift.Result?) throws -> T { + if let result = result { + switch result { + case .success(let value): + return value + case .failure(let error): + throw error + } + } else { + fatalError("Suspend execution ended without result! This is most likely a bug in SKIE.") + } + } +} + +// FILE: Skie/Skie.bridgeSubscriptionCount.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +func bridgeSubscriptionCount(_ subscriptionCount: Store6CoreSkie.SkieSwiftStateFlow) -> Store6CoreSkie.SkieSwiftStateFlow { + return subscriptionCount +} + +func bridgeSubscriptionCount(_ subscriptionCount: any Store6CoreSkie.Kotlinx_coroutines_coreStateFlow) -> Store6CoreSkie.SkieSwiftStateFlow { + return Store6CoreSkie.SkieSwiftStateFlow(internal: subscriptionCount) +} + +// FILE: Stdlib/Stdlib.SuspendFunction0.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6CoreSkie.KotlinSuspendFunction0 { + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func invoke() async throws -> Any? { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__28__invoke(dispatchReceiver: self, suspendHandler: $0) + } + } + +} + +// FILE: Stdlib/Stdlib.SuspendFunction1.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6CoreSkie.KotlinSuspendFunction1 { + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func invoke(p1: Any?) async throws -> Any? { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__13__invoke(dispatchReceiver: self, p1: p1, suspendHandler: $0) + } + } + +} + +// FILE: Store6Core/Store6Core.Bookkeeper.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6CoreSkie.Bookkeeper { + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func advanceGlobalStaleWatermark() async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__15__advanceGlobalStaleWatermark(dispatchReceiver: self, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func advanceStaleWatermark(namespace: Store6CoreSkie.StoreNamespace) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__16__advanceStaleWatermark(dispatchReceiver: self, namespace: namespace, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func forget(key: Store6CoreSkie.StoreKey) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__17__forget(dispatchReceiver: self, key: key, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func forgetAll() async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__18__forgetAll(dispatchReceiver: self, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func forgetNamespace(namespace: Store6CoreSkie.StoreNamespace) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__19__forgetNamespace(dispatchReceiver: self, namespace: namespace, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func markStale(key: Store6CoreSkie.StoreKey) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__20__markStale(dispatchReceiver: self, key: key, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func recordFailure(key: Store6CoreSkie.StoreKey, atEpochMillis: Swift.Int64) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__21__recordFailure(dispatchReceiver: self, key: key, atEpochMillis: atEpochMillis, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func recordSuccess(key: Store6CoreSkie.StoreKey, meta: Store6CoreSkie.StoreMeta) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__22__recordSuccess(dispatchReceiver: self, key: key, meta: meta, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func status(key: Store6CoreSkie.StoreKey) async throws -> Store6CoreSkie.KeyStatus? { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__23__status(dispatchReceiver: self, key: key, suspendHandler: $0) + } + } + +} + +// FILE: Store6Core/Store6Core.FetchPlan.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6CoreSkie.Skie.Store6Core.FetchPlan { + + @frozen + public enum __Sealed : Swift.Hashable { + + case conditional(Store6CoreSkie.FetchPlanConditional) + case fetch(Store6CoreSkie.FetchPlanFetch) + case skip(Store6CoreSkie.FetchPlanSkip) + + } + +} + +public func onEnum<__Sealed : Store6CoreSkie.FetchPlan>(of sealed: __Sealed) -> Store6CoreSkie.Skie.Store6Core.FetchPlan.__Sealed { + if let sealed = sealed as? Store6CoreSkie.FetchPlanConditional { + return Store6CoreSkie.Skie.Store6Core.FetchPlan.__Sealed.conditional(sealed) + } else if let sealed = sealed as? Store6CoreSkie.FetchPlanFetch { + return Store6CoreSkie.Skie.Store6Core.FetchPlan.__Sealed.fetch(sealed) + } else if let sealed = sealed as? Store6CoreSkie.FetchPlanSkip { + return Store6CoreSkie.Skie.Store6Core.FetchPlan.__Sealed.skip(sealed) + } else { + fatalError("Unknown subtype \(sealed). This error should not happen under normal circumstances since SirClass: Store6CoreSkie.FetchPlan is sealed.") + } +} + +@_disfavoredOverload +public func onEnum<__Sealed : Store6CoreSkie.FetchPlan>(of sealed: __Sealed?) -> Store6CoreSkie.Skie.Store6Core.FetchPlan.__Sealed? { + if let sealed { + return onEnum(of: sealed) as Store6CoreSkie.Skie.Store6Core.FetchPlan.__Sealed + } else { + return nil + } +} + +// FILE: Store6Core/Store6Core.Fetcher.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6CoreSkie.Fetcher { + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func fetch(key: Store6CoreSkie.StoreKey, etag: Swift.String?) async throws -> Store6CoreSkie.FetcherResult { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__14__fetch(dispatchReceiver: self, key: key, etag: etag, suspendHandler: $0) + } + } + +} + +// FILE: Store6Core/Store6Core.FetcherResult.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6CoreSkie.Skie.Store6Core.FetcherResult { + + @frozen + public enum __Sealed : Swift.Hashable { + + case deleted(Store6CoreSkie.FetcherResultDeleted) + case error(Store6CoreSkie.FetcherResultError) + case notModified(Store6CoreSkie.FetcherResultNotModified) + case success(Store6CoreSkie.FetcherResultSuccess) + + } + +} + +public func onEnum<__Sealed : Store6CoreSkie.FetcherResult>(of sealed: __Sealed) -> Store6CoreSkie.Skie.Store6Core.FetcherResult.__Sealed { + if let sealed = sealed as? Store6CoreSkie.FetcherResultDeleted { + return Store6CoreSkie.Skie.Store6Core.FetcherResult.__Sealed.deleted(sealed) + } else if let sealed = sealed as? Store6CoreSkie.FetcherResultError { + return Store6CoreSkie.Skie.Store6Core.FetcherResult.__Sealed.error(sealed) + } else if let sealed = sealed as? Store6CoreSkie.FetcherResultNotModified { + return Store6CoreSkie.Skie.Store6Core.FetcherResult.__Sealed.notModified(sealed) + } else if let sealed = sealed as? Store6CoreSkie.FetcherResultSuccess { + return Store6CoreSkie.Skie.Store6Core.FetcherResult.__Sealed.success(sealed) + } else { + fatalError("Unknown subtype \(sealed). This error should not happen under normal circumstances since SirClass: Store6CoreSkie.FetcherResult is sealed.") + } +} + +@_disfavoredOverload +public func onEnum<__Sealed : Store6CoreSkie.FetcherResult>(of sealed: __Sealed?) -> Store6CoreSkie.Skie.Store6Core.FetcherResult.__Sealed? { + if let sealed { + return onEnum(of: sealed) as Store6CoreSkie.Skie.Store6Core.FetcherResult.__Sealed + } else { + return nil + } +} + +// FILE: Store6Core/Store6Core.Freshness.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6CoreSkie.Skie.Store6Core.Freshness { + + @frozen + public enum __Sealed : Swift.Hashable { + + case cachedOrFetch(Store6CoreSkie.FreshnessCachedOrFetch) + case localOnly(Store6CoreSkie.FreshnessLocalOnly) + case maxAge(Store6CoreSkie.FreshnessMaxAge) + case mustBeFresh(Store6CoreSkie.FreshnessMustBeFresh) + case staleIfError(Store6CoreSkie.FreshnessStaleIfError) + + } + +} + +public func onEnum<__Sealed : Store6CoreSkie.Freshness>(of sealed: __Sealed) -> Store6CoreSkie.Skie.Store6Core.Freshness.__Sealed { + if let sealed = sealed as? Store6CoreSkie.FreshnessCachedOrFetch { + return Store6CoreSkie.Skie.Store6Core.Freshness.__Sealed.cachedOrFetch(sealed) + } else if let sealed = sealed as? Store6CoreSkie.FreshnessLocalOnly { + return Store6CoreSkie.Skie.Store6Core.Freshness.__Sealed.localOnly(sealed) + } else if let sealed = sealed as? Store6CoreSkie.FreshnessMaxAge { + return Store6CoreSkie.Skie.Store6Core.Freshness.__Sealed.maxAge(sealed) + } else if let sealed = sealed as? Store6CoreSkie.FreshnessMustBeFresh { + return Store6CoreSkie.Skie.Store6Core.Freshness.__Sealed.mustBeFresh(sealed) + } else if let sealed = sealed as? Store6CoreSkie.FreshnessStaleIfError { + return Store6CoreSkie.Skie.Store6Core.Freshness.__Sealed.staleIfError(sealed) + } else { + fatalError("Unknown subtype \(sealed). This error should not happen under normal circumstances since SirClass: Store6CoreSkie.Freshness is sealed.") + } +} + +@_disfavoredOverload +public func onEnum<__Sealed : Store6CoreSkie.Freshness>(of sealed: __Sealed?) -> Store6CoreSkie.Skie.Store6Core.Freshness.__Sealed? { + if let sealed { + return onEnum(of: sealed) as Store6CoreSkie.Skie.Store6Core.Freshness.__Sealed + } else { + return nil + } +} + +// FILE: Store6Core/Store6Core.Origin.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +@frozen +public enum Origin : Swift.Hashable, Swift.CaseIterable, Swift._ObjectiveCBridgeable { + + case memory + case sot + case fetcher + case overlay + + public var name: Swift.String { + return (self as _ObjectiveCType).name + } + + public var ordinal: Swift.Int32 { + return (self as _ObjectiveCType).ordinal + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6CoreSkie.__Origin, result: inout Store6CoreSkie.Origin?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.__Origin, result: inout Store6CoreSkie.Origin?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.__Origin?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6CoreSkie.__Origin { + switch self { + case .memory: return Store6CoreSkie.__Origin.memory as Store6CoreSkie.__Origin + case .sot: return Store6CoreSkie.__Origin.sot as Store6CoreSkie.__Origin + case .fetcher: return Store6CoreSkie.__Origin.fetcher as Store6CoreSkie.__Origin + case .overlay: return Store6CoreSkie.__Origin.overlay as Store6CoreSkie.__Origin + } + } + + private static func fromObjectiveC(_ source: Store6CoreSkie.__Origin?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6CoreSkie.Origin") + } + if source == Store6CoreSkie.__Origin.memory as Store6CoreSkie.__Origin { + return .memory + } else if source == Store6CoreSkie.__Origin.sot as Store6CoreSkie.__Origin { + return .sot + } else if source == Store6CoreSkie.__Origin.fetcher as Store6CoreSkie.__Origin { + return .fetcher + } else if source == Store6CoreSkie.__Origin.overlay as Store6CoreSkie.__Origin { + return .overlay + } else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6CoreSkie.__Origin") + } + } + + public typealias _ObjectiveCType = Store6CoreSkie.__Origin + +} + +extension Store6CoreSkie.Origin { + + public func toKotlinEnum() -> Store6CoreSkie.__Origin { + return _bridgeToObjectiveC() + } + +} + +extension Store6CoreSkie.__Origin { + + public func toSwiftEnum() -> Store6CoreSkie.Origin { + return Store6CoreSkie.Origin._unconditionallyBridgeFromObjectiveC(self) + } + +} + +// FILE: Store6Core/Store6Core.SourceOfTruth.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6CoreSkie.SourceOfTruth { + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func delete(key: Store6CoreSkie.StoreKey) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__9__delete(dispatchReceiver: self, key: key, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func deleteAll() async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__10__deleteAll(dispatchReceiver: self, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func deleteNamespace(namespace: Store6CoreSkie.StoreNamespace) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__11__deleteNamespace(dispatchReceiver: self, namespace: namespace, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func write(key: Store6CoreSkie.StoreKey, value: Any) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__12__write(dispatchReceiver: self, key: key, value: value, suspendHandler: $0) + } + } + +} + +// FILE: Store6Core/Store6Core.Store.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6CoreSkie.Store { + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func clear(key: Store6CoreSkie.StoreKey) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__0__clear(dispatchReceiver: self, key: key, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func clearAll() async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__1__clearAll(dispatchReceiver: self, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func clearNamespace(namespace: Store6CoreSkie.StoreNamespace) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__2__clearNamespace(dispatchReceiver: self, namespace: namespace, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func get(key: Store6CoreSkie.StoreKey, freshness: Store6CoreSkie.Freshness) async throws -> Any { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__3__get(dispatchReceiver: self, key: key, freshness: freshness, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func invalidate(key: Store6CoreSkie.StoreKey) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__4__invalidate(dispatchReceiver: self, key: key, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func invalidateAll() async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__5__invalidateAll(dispatchReceiver: self, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func invalidateNamespace(namespace: Store6CoreSkie.StoreNamespace) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__6__invalidateNamespace(dispatchReceiver: self, namespace: namespace, suspendHandler: $0) + } + } + + public func runtime() -> Store6CoreSkie.StoreRuntime? { + return Store6CoreSkie.StoreRuntimeKt.runtime(self) + } + +} + +// FILE: Store6Core/Store6Core.StoreBuilderKt.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public func store(configure: @escaping (Store6CoreSkie.StoreBuilder) -> Swift.Void) -> Store6CoreSkie.Store { + return Store6CoreSkie.StoreBuilderKt.store(configure: configure) +} + +// FILE: Store6Core/Store6Core.StoreError.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6CoreSkie.Skie.Store6Core.StoreError { + + @frozen + public enum __Sealed : Swift.Hashable { + + case conflict(Store6CoreSkie.StoreError.Conflict) + case conversion(Store6CoreSkie.StoreError.Conversion) + case fetch(Store6CoreSkie.StoreError.Fetch) + case freshnessUnsatisfiable(Store6CoreSkie.StoreError.FreshnessUnsatisfiable) + case missing(Store6CoreSkie.StoreError.Missing) + case persistence(Store6CoreSkie.StoreError.Persistence) + + } + +} + +public func onEnum<__Sealed : Store6CoreSkie.StoreError>(of sealed: __Sealed) -> Store6CoreSkie.Skie.Store6Core.StoreError.__Sealed { + if let sealed = sealed as? Store6CoreSkie.StoreError.Conflict { + return Store6CoreSkie.Skie.Store6Core.StoreError.__Sealed.conflict(sealed) + } else if let sealed = sealed as? Store6CoreSkie.StoreError.Conversion { + return Store6CoreSkie.Skie.Store6Core.StoreError.__Sealed.conversion(sealed) + } else if let sealed = sealed as? Store6CoreSkie.StoreError.Fetch { + return Store6CoreSkie.Skie.Store6Core.StoreError.__Sealed.fetch(sealed) + } else if let sealed = sealed as? Store6CoreSkie.StoreError.FreshnessUnsatisfiable { + return Store6CoreSkie.Skie.Store6Core.StoreError.__Sealed.freshnessUnsatisfiable(sealed) + } else if let sealed = sealed as? Store6CoreSkie.StoreError.Missing { + return Store6CoreSkie.Skie.Store6Core.StoreError.__Sealed.missing(sealed) + } else if let sealed = sealed as? Store6CoreSkie.StoreError.Persistence { + return Store6CoreSkie.Skie.Store6Core.StoreError.__Sealed.persistence(sealed) + } else { + fatalError("Unknown subtype \(sealed). This error should not happen under normal circumstances since SirClass: Store6CoreSkie.StoreError is sealed.") + } +} + +@_disfavoredOverload +public func onEnum<__Sealed : Store6CoreSkie.StoreError>(of sealed: __Sealed?) -> Store6CoreSkie.Skie.Store6Core.StoreError.__Sealed? { + if let sealed { + return onEnum(of: sealed) as Store6CoreSkie.Skie.Store6Core.StoreError.__Sealed + } else { + return nil + } +} + +// FILE: Store6Core/Store6Core.StoreResult.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6CoreSkie.Skie.Store6Core.StoreResult { + + @frozen + public enum __Sealed : Swift.Hashable { + + case data(Store6CoreSkie.StoreResultData) + case error(Store6CoreSkie.StoreResultError) + case loading(Store6CoreSkie.StoreResultLoading) + case revalidated(Store6CoreSkie.StoreResultRevalidated) + + } + +} + +public func onEnum<__Sealed : Store6CoreSkie.StoreResult>(of sealed: __Sealed) -> Store6CoreSkie.Skie.Store6Core.StoreResult.__Sealed { + if let sealed = sealed as? Store6CoreSkie.StoreResultData { + return Store6CoreSkie.Skie.Store6Core.StoreResult.__Sealed.data(sealed) + } else if let sealed = sealed as? Store6CoreSkie.StoreResultError { + return Store6CoreSkie.Skie.Store6Core.StoreResult.__Sealed.error(sealed) + } else if let sealed = sealed as? Store6CoreSkie.StoreResultLoading { + return Store6CoreSkie.Skie.Store6Core.StoreResult.__Sealed.loading(sealed) + } else if let sealed = sealed as? Store6CoreSkie.StoreResultRevalidated { + return Store6CoreSkie.Skie.Store6Core.StoreResult.__Sealed.revalidated(sealed) + } else { + fatalError("Unknown subtype \(sealed). This error should not happen under normal circumstances since SirClass: Store6CoreSkie.StoreResult is sealed.") + } +} + +@_disfavoredOverload +public func onEnum<__Sealed : Store6CoreSkie.StoreResult>(of sealed: __Sealed?) -> Store6CoreSkie.Skie.Store6Core.StoreResult.__Sealed? { + if let sealed { + return onEnum(of: sealed) as Store6CoreSkie.Skie.Store6Core.StoreResult.__Sealed + } else { + return nil + } +} + +// FILE: Store6Core/Store6Core.StoreWriteHandle.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6CoreSkie.StoreWriteHandle { + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func apply(key: Store6CoreSkie.StoreKey, value: Any) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__24__apply(dispatchReceiver: self, key: key, value: value, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func confirmFresh(key: Store6CoreSkie.StoreKey, etag: Swift.String?) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__25__confirmFresh(dispatchReceiver: self, key: key, etag: etag, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func markStale(key: Store6CoreSkie.StoreKey) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__26__markStale(dispatchReceiver: self, key: key, suspendHandler: $0) + } + } + +} + +// FILE: Store6Core/Store6Core.TransactionalSourceOfTruth.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6CoreSkie.TransactionalSourceOfTruth { + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func withTransaction(block: Store6CoreSkie.KotlinSuspendFunction0) async throws -> Any? { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__27__withTransaction(dispatchReceiver: self, block: block, suspendHandler: $0) + } + } + +} diff --git a/store6-core/build.gradle.kts b/store6-core/build.gradle.kts new file mode 100644 index 000000000..3f50f50bb --- /dev/null +++ b/store6-core/build.gradle.kts @@ -0,0 +1,37 @@ +plugins { + id("org.mobilenativefoundation.store.store6.multiplatform") +} + +kotlin { + js { + nodejs { + testTask { + useMocha { + // Keep the runner above the 240s StoreInvalidationStressTest watchdog so + // runTest always owns cancellation and cleanup. + timeout = "300s" + } + } + } + } + + sourceSets { + val commonMain by getting { + dependencies { + api(libs.kotlinx.coroutines.core) + } + } + + val commonTest by getting { + dependencies { + implementation(projects.store6Testing) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.turbine) + } + } + } +} + +android { + namespace = "org.mobilenativefoundation.store6.core" +} diff --git a/store6-core/dokka/Module.md b/store6-core/dokka/Module.md new file mode 100644 index 000000000..8e2d4f50e --- /dev/null +++ b/store6-core/dokka/Module.md @@ -0,0 +1,17 @@ +# Module store6-core + +Store 6 is in development and nothing is published yet. This reference describes the API as it +stands on `main`; install coordinates begin with 6.0.0-alpha01. + +## Stability tier + +`store6-core` is stable-track. Its API is not frozen until the beta01 freeze candidate. + +The `org.mobilenativefoundation.store6.core.seam` package is a freeze candidate, not frozen. Its +types are currently marked `@ExperimentalStoreApi`, so implementing a fetcher, source of truth, +bookkeeper, clock, telemetry sink, or overlay is an explicit opt-in. + +Return to [Docs home](https://store.mobilenativefoundation.org/docs), or use the +[Store 6 overview](https://store.mobilenativefoundation.org/docs/store6/overview) for guides and +examples. Writing APIs are in the +[store6-mutations reference](https://store.mobilenativefoundation.org/reference/store6-mutations/index.html). diff --git a/store6-core/gradle.properties b/store6-core/gradle.properties new file mode 100644 index 000000000..768418c29 --- /dev/null +++ b/store6-core/gradle.properties @@ -0,0 +1,3 @@ +VERSION_NAME=6.0.0-SNAPSHOT +POM_NAME=store6-core +POM_ARTIFACT_ID=store6-core diff --git a/core/config/ktlint/baseline.xml b/store6-core/src/androidMain/AndroidManifest.xml similarity index 51% rename from core/config/ktlint/baseline.xml rename to store6-core/src/androidMain/AndroidManifest.xml index 981420778..8072ee00d 100644 --- a/core/config/ktlint/baseline.xml +++ b/store6-core/src/androidMain/AndroidManifest.xml @@ -1,3 +1,2 @@ - - + diff --git a/store6-core/src/androidMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.android.kt b/store6-core/src/androidMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.android.kt new file mode 100644 index 000000000..dbf43f9c0 --- /dev/null +++ b/store6-core/src/androidMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.android.kt @@ -0,0 +1,4 @@ +package org.mobilenativefoundation.store6.core.internal + +/** Returns the Android system wall clock in Unix epoch milliseconds. */ +internal actual fun currentEpochMillis(): Long = System.currentTimeMillis() diff --git a/store6-core/src/appleMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.apple.kt b/store6-core/src/appleMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.apple.kt new file mode 100644 index 000000000..d6ab14697 --- /dev/null +++ b/store6-core/src/appleMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.apple.kt @@ -0,0 +1,8 @@ +package org.mobilenativefoundation.store6.core.internal + +import platform.Foundation.NSDate +import platform.Foundation.timeIntervalSince1970 + +/** Returns the Apple system wall clock in Unix epoch milliseconds. */ +internal actual fun currentEpochMillis(): Long = + (NSDate().timeIntervalSince1970 * 1_000.0).toLong() diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Annotations.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Annotations.kt new file mode 100644 index 000000000..827aa29bb --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Annotations.kt @@ -0,0 +1,64 @@ +package org.mobilenativefoundation.store6.core + +/** + * Marks API that is under active development and may change or be removed in any release. + * + * Experimental API ships in separate artifacts wherever possible; this marker exists for the + * cases where an experimental member must live beside stable API. + */ +@RequiresOptIn( + level = RequiresOptIn.Level.ERROR, + message = "This Store API is experimental and may change or be removed in any release.", +) +@Retention(AnnotationRetention.BINARY) +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY, + AnnotationTarget.CONSTRUCTOR, + AnnotationTarget.TYPEALIAS, +) +@MustBeDocumented +public annotation class ExperimentalStoreApi + +/** + * Marks API that is stable but easy to misuse, such as implementing [Store] directly instead of + * building one through the [store] DSL. Opting in asserts that the caller upholds the documented + * contract of the marked declaration. + */ +@RequiresOptIn( + level = RequiresOptIn.Level.ERROR, + message = + "This is a delicate Store API. Read the contract documentation of the declaration " + + "before opting in; implementations must uphold every documented semantic.", +) +@Retention(AnnotationRetention.BINARY) +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY, + AnnotationTarget.CONSTRUCTOR, + AnnotationTarget.TYPEALIAS, +) +@MustBeDocumented +public annotation class DelicateStoreApi + +/** + * Marks API that is internal to the Store libraries. It may change or disappear without notice + * even in patch releases and must never be used outside org.mobilenativefoundation.store + * artifacts. + */ +@RequiresOptIn( + level = RequiresOptIn.Level.ERROR, + message = "This API is internal to Store and must not be used outside Store artifacts.", +) +@Retention(AnnotationRetention.BINARY) +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY, + AnnotationTarget.CONSTRUCTOR, + AnnotationTarget.TYPEALIAS, +) +@MustBeDocumented +public annotation class InternalStoreApi diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Freshness.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Freshness.kt new file mode 100644 index 000000000..e1c02344d --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Freshness.kt @@ -0,0 +1,52 @@ +package org.mobilenativefoundation.store6.core + +import kotlin.time.Duration + +/** + * A per-call policy describing how fresh a value must be before it is served. + * + * Each read is planned from resident availability, invalidation state, typed metadata, and this + * policy. Concurrent requests for one key still share a single in-flight fetch even when their + * policies differ. + */ +public sealed interface Freshness { + /** + * The default policy: serve a locally available value immediately. Invalidated values and + * source-of-truth rows without freshness metadata are served as stale while one background + * revalidation runs; fetch when no local value exists. + */ + public data object CachedOrFetch : Freshness + + /** + * Serve a locally available value only when it has known freshness metadata, has not been + * invalidated, and its age does not exceed `notOlderThan`; otherwise withhold it and fetch a + * fresh value. + */ + public class MaxAge( + /** The oldest a served value may be before a fetch is required. */ + public val notOlderThan: Duration, + ) : Freshness + + /** + * Never serve a cached value; block until a fresh fetch succeeds and fail when it does not. A + * source-of-truth row without freshness metadata is also withheld. + */ + public data object MustBeFresh : Freshness + + /** + * Prefer fresh data after invalidation or when a local value has no freshness metadata, but + * fall back to that stale value when the fetch fails. A local value with current known + * metadata is served without fetching. + */ + public data object StaleIfError : Freshness + + /** + * Never invoke the fetcher; serve only locally available data and report + * [StoreError.Missing] when none exists. + * + * On a memory miss, the configured source of truth is probed once, so a pre-existing persisted + * row is locally available data. The builder still requires a fetcher, but LocalOnly never + * invokes it. + */ + public data object LocalOnly : Freshness +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Origin.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Origin.kt new file mode 100644 index 000000000..d1fe4efd4 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Origin.kt @@ -0,0 +1,16 @@ +package org.mobilenativefoundation.store6.core + +/** Identifies the source from which a [StoreResult.Data] value was obtained. */ +public enum class Origin { + /** The value was served from in-memory resident state. */ + MEMORY, + + /** The value was read from the store's persistent source of truth. */ + SOT, + + /** The value was produced by the store's configured fetcher. */ + FETCHER, + + /** The value reflects an overlay applied above stored data. */ + OVERLAY, +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Store.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Store.kt new file mode 100644 index 000000000..2a9bf2b08 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Store.kt @@ -0,0 +1,174 @@ +package org.mobilenativefoundation.store6.core + +import kotlinx.coroutines.flow.Flow + +/** + * Provides asynchronous access to non-null values identified by [StoreKey] instances. + * + * Create stores through the [store] DSL. Implementing this interface directly is a delicate + * operation: implementations must uphold every documented semantic, including the one-failure- + * channel rule ([stream] emits and never throws; [get] throws and never emits) and the + * completion postconditions of the maintenance operations. + * + * @param K the key type accepted by this store + * @param V the non-null value type produced by this store + */ +@SubclassOptInRequired(DelicateStoreApi::class) +public interface Store { + /** + * Observes retrieval state and values for [key]. + * + * Fetcher and source-of-truth failures encountered while retrieving [key] are emitted as + * [StoreResult.Error] values rather than thrown to the collector. A + * [Freshness.MustBeFresh] initial-cycle fetch or revalidation failure emits one error and + * completes the flow; every other failure leaves the flow live. Otherwise, the flow remains + * active until its collector is cancelled or the store is closed, and continues to report + * later values, including refetches triggered by invalidation and the absent-value transition + * after a clear. Concurrent collectors and callers for one key share a single fetch. + * + * [Freshness.CachedOrFetch] serves a resident value and refreshes it after invalidation; + * [Freshness.MaxAge] withholds an invalidated or over-age resident until a fetch succeeds; + * [Freshness.MustBeFresh] always withholds residence and treats an initial-cycle failure as + * terminal; [Freshness.StaleIfError] serves invalidated residence while reporting a failed + * refresh; and [Freshness.LocalOnly] never fetches. + * + * @param key the key to observe + * @param freshness the freshness policy applied to this observation + * @return a flow of loading, data, revalidation, and error results for the key + * @throws IllegalStateException if the store is closed before this call or before collection + * begins + */ + public fun stream( + key: K, + freshness: Freshness = Freshness.CachedOrFetch, + ): Flow> + + /** + * Returns the value for [key] according to [freshness]. + * + * [Freshness.CachedOrFetch] returns residence immediately and refreshes it in the background + * after invalidation. [Freshness.MaxAge] and [Freshness.MustBeFresh] block for a qualifying + * fetch. [Freshness.StaleIfError] blocks after invalidation and returns the resident value only + * when the refresh fails. [Freshness.LocalOnly] never fetches. + * + * This read is never projected by a configured overlay; overlays apply only to [stream]. + * + * @param key the key whose value is requested + * @param freshness the freshness policy applied to this read + * @return the resolved value + * @throws StoreException when no value can be returned because fetching or source-of-truth + * access failed, a concurrent [clear] removed the key while its fetch was in flight, + * [Freshness.LocalOnly] found no local value, or the server reported deletion + * ([StoreError.Missing]) + * @throws IllegalStateException if the store is already closed + */ + public suspend fun get( + key: K, + freshness: Freshness = Freshness.CachedOrFetch, + ): V + + /** + * Marks the value for [key] stale without removing it. + * + * On return, active streams of [key] have been signaled and will observe refetched data; + * the resident value is kept and served as stale in the meantime. Invalidation is + * level-triggered monotone state, so a signal issued during any race window is never lost. + * + * The stale mark is durable and survives process restart until a later successful fetch or + * revalidation clears it. + * + * @param key the key to invalidate + * @throws StoreException if persisting the stale mark fails; resident state is not signaled + * @throws IllegalStateException if the store is already closed + */ + public suspend fun invalidate(key: K) + + /** + * Marks every key in [namespace] stale without removing values. + * + * The durable namespace watermark covers keys whether or not they are currently resident. + * Resident streams are signaled on return unless a later successful fetch already superseded + * the watermark. + * + * @param namespace the namespace to invalidate + * @throws StoreException if persisting the namespace watermark fails; resident state is not + * signaled + * @throws IllegalStateException if the store is already closed + */ + public suspend fun invalidateNamespace(namespace: StoreNamespace) + + /** + * Marks every key in this store stale without removing values. + * + * The durable global watermark covers every namespace, including keys that are not currently + * resident. Resident streams are signaled on return unless a later successful fetch already + * superseded the watermark. + * + * @throws StoreException if persisting the global watermark fails; resident state is not + * signaled + * @throws IllegalStateException if the store is already closed + */ + public suspend fun invalidateAll() + + /** + * Destructively removes the value for [key]. + * + * On return, the resident value is gone: active streams observe the absent-value transition + * ([StoreResult.Loading]) and then refetched data, and an in-flight fetch that started + * before the clear can no longer commit — its waiters observe [StoreError.Missing]. + * + * Removal includes the configured source-of-truth row and its freshness bookkeeping for + * [key]. + * + * @param key the key to clear + * @throws StoreException if deleting the configured source-of-truth row fails (engine state + * remains unchanged), or if freshness cleanup fails after the row and resident state were + * removed + * @throws IllegalStateException if the store is already closed + */ + public suspend fun clear(key: K) + + /** + * Destructively removes every value in [namespace]. + * + * A scoped fence drains affected commit atoms before a resident supersede sweep, blocks new + * affected commits through the source-of-truth delete and bookkeeping cleanup, then performs + * a purge sweep. A fetcher call may finish while fenced, but its commit waits; tickets present + * before purge are superseded. While the call is in flight, an already-running read may briefly + * observe the old row and an active collector may receive one queued duplicate old [StoreResult.Data]. + * The purge closes the registry-snapshot and rehydration window before normal return. Later + * demand and writes made outside this Store remain later authority. + * + * @param namespace the namespace to clear + * @throws StoreException if durable deletion or bookkeeping cleanup fails; completed earlier + * steps remain applied and conservative + * @throws IllegalStateException if the store is already closed + */ + public suspend fun clearNamespace(namespace: StoreNamespace) + + /** + * Destructively removes every value in this store. + * + * A global fence drains every commit atom before a resident supersede sweep, blocks new commits + * through the source-of-truth delete and bookkeeping cleanup, then performs a purge sweep. A + * fetcher call may finish while fenced, but its commit waits; tickets present before purge are + * superseded. While the call is in flight, an already-running read may briefly observe the old + * row and an active collector may receive one queued duplicate old [StoreResult.Data]. The purge + * closes the registry-snapshot and rehydration window before normal return. Later demand and + * writes made outside this Store remain later authority. + * + * @throws StoreException if durable deletion or bookkeeping cleanup fails; completed earlier + * steps remain applied and conservative + * @throws IllegalStateException if the store is already closed + */ + public suspend fun clearAll() + + /** + * Releases resources owned by this store and cancels its in-flight work. + * + * Collectors and value requests waiting on in-flight work are cancelled. Subsequent calls + * to any operation fail with [IllegalStateException] and the message `Store is closed.` + * Calling `close()` more than once has no additional effect. + */ + public fun close() +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreBuilder.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreBuilder.kt new file mode 100644 index 000000000..80b922b61 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreBuilder.kt @@ -0,0 +1,192 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.core + +import org.mobilenativefoundation.store6.core.internal.DEFAULT_MAX_IDLE_ENGINES +import org.mobilenativefoundation.store6.core.internal.InMemoryBookkeeper +import org.mobilenativefoundation.store6.core.internal.InMemorySourceOfTruth +import org.mobilenativefoundation.store6.core.internal.DefaultFreshnessValidator +import org.mobilenativefoundation.store6.core.internal.LambdaFetcher +import org.mobilenativefoundation.store6.core.internal.RealStore +import org.mobilenativefoundation.store6.core.internal.ResultFetcher +import org.mobilenativefoundation.store6.core.internal.SystemWallClock +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.core.seam.Fetcher +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import org.mobilenativefoundation.store6.core.seam.FreshnessValidator +import org.mobilenativefoundation.store6.core.seam.Overlay +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import org.mobilenativefoundation.store6.core.seam.StoreTelemetry +import org.mobilenativefoundation.store6.core.seam.WallClock + +/** + * Creates a [Store] using the settings supplied by [configure]. + * + * @param K the key type accepted by the store + * @param V the non-null value type produced by the store + * @param configure configuration applied before the store is created + * @return the configured store + * @throws IllegalArgumentException if no fetcher is configured + */ +public fun store( + configure: StoreBuilder.() -> Unit, +): Store = StoreBuilder().apply(configure).build() + +/** + * Configuration receiver for the [store] creation DSL. + * + * @param K the key type accepted by the store + * @param V the non-null value type produced by the store + */ +public class StoreBuilder internal constructor() { + @OptIn(ExperimentalStoreApi::class) + private var fetcher: Fetcher? = null + + @OptIn(ExperimentalStoreApi::class) + private var sot: SourceOfTruth? = null + + @OptIn(ExperimentalStoreApi::class) + private var wallClock: WallClock = SystemWallClock + + @OptIn(ExperimentalStoreApi::class) + private var bookkeeper: Bookkeeper = InMemoryBookkeeper() + + @OptIn(ExperimentalStoreApi::class) + private var validator: FreshnessValidator = DefaultFreshnessValidator + + @OptIn(ExperimentalStoreApi::class) + private var telemetry: StoreTelemetry? = null + + @OptIn(ExperimentalStoreApi::class) + private var overlay: Overlay? = null + + private var maxIdleKeys: Int = DEFAULT_MAX_IDLE_ENGINES + + /** + * Configures the suspending function used to retrieve a value for a key. + * + * This is success-or-throw sugar for [fetcherOfResult]: a returned value becomes + * [FetcherResult.Success], while a thrown exception propagates through the store's fetch-failure + * path. The last registration wins across this function, [fetcherOfResult], and the regular- + * interface [fetcher] overload. + * + * @param fetch the function that retrieves and returns a value for the supplied key + */ + public fun fetcher(fetch: suspend (K) -> V) { + this.fetcher = LambdaFetcher(fetch) + } + + /** + * Configures a fetcher that returns the full [FetcherResult] vocabulary. + * + * The name follows v5's `Fetcher.ofResult`. The regular-interface [fetcher] overload cannot + * accept a lambda, so lambda-return-type inference remains unambiguous. The last registration + * wins across all three fetcher install points. + * + * @param fetch the function that returns a rich result for the supplied key + */ + public fun fetcherOfResult(fetch: suspend (K) -> FetcherResult) { + this.fetcher = ResultFetcher(fetch) + } + + /** + * Installs a regular-interface [Fetcher] that can receive conditional-request ETags. + * + * This overload is regular-interface-only: [Fetcher] is deliberately not a fun interface, so + * lambda calls continue to resolve to the success-or-throw [fetcher] function. The last + * registration wins across all three fetcher install points. + * + * @param fetcher the fetch source installed for this store + */ + @ExperimentalStoreApi + public fun fetcher(fetcher: Fetcher) { + this.fetcher = fetcher + } + + /** + * Bounds quiescent per-key engine residency. + * + * Engines whose key has active collectors, in-flight work, or an in-flight fetch are always + * resident and are never evicted. Once a key becomes quiescent its engine parks in an LRU idle + * set holding at most [count] engines; the eldest quiescent engine beyond the bound is destroyed. + * Eviction discards only derived in-memory state — durable rows, freshness metadata, stale marks, + * and watermarks live in the source of truth and bookkeeper, so a later read of an evicted key is + * semantically identical to one that was never evicted. `0` destroys every engine at quiescence. + * + * @param count the maximum number of quiescent engines retained; must be >= 0. Default 128. + */ + public fun maxIdleKeys(count: Int) { + require(count >= 0) { "maxIdleKeys must be >= 0, was $count." } + maxIdleKeys = count + } + + /** + * Selects the persistence seam for this store. + * + * The engine reads [SourceOfTruth.reader], writes successfully fetched values through + * [SourceOfTruth.write], and treats [SourceOfTruth.delete] as destructive persistence removal. + * When the stored selection is consumed by the engine, an absent block installs the internal + * in-memory default. Custom implementations should be validated with the source-of-truth + * contract kit. + * + * @param sot the source of truth used by the store + */ + @ExperimentalStoreApi + public fun persistence(sot: SourceOfTruth) { + this.sot = sot + } + + /** Installs a non-blocking lifecycle observer; leaving it unset preserves the null fast path. */ + @ExperimentalStoreApi + public fun telemetry(telemetry: StoreTelemetry) { + this.telemetry = telemetry + } + + /** + * Installs the stream-only projection layer for this store. + * + * The last registration wins. Leaving this unset preserves the direct-residence fast path and + * allocates no projection writer or readiness state. + */ + @ExperimentalStoreApi + public fun overlay(overlay: Overlay) { + this.overlay = overlay + } + + /** Installs the durable freshness bookkeeping implementation used by this store. */ + @ExperimentalStoreApi + public fun bookkeeper(bookkeeper: Bookkeeper) { + this.bookkeeper = bookkeeper + } + + /** Installs the wall clock used for age and freshness-bound calculations. */ + @ExperimentalStoreApi + public fun wallClock(wallClock: WallClock) { + this.wallClock = wallClock + } + + /** Installs the policy planner used to select a fetch plan for each coherent read snapshot. */ + @ExperimentalStoreApi + public fun freshnessValidator(validator: FreshnessValidator) { + this.validator = validator + } + + @OptIn(ExperimentalStoreApi::class) + internal fun build(): Store { + val fetch = requireNotNull(fetcher) { + "store { } requires a fetcher { }, fetcherOfResult { }, or " + + "fetcher(Fetcher) block." + } + val sourceOfTruth = sot ?: InMemorySourceOfTruth() + return RealStore( + fetch, + sourceOfTruth, + wallClock, + bookkeeper, + validator, + telemetry, + overlay, + maxIdleKeys, + ) + } +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreError.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreError.kt new file mode 100644 index 000000000..db317bbe7 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreError.kt @@ -0,0 +1,61 @@ +package org.mobilenativefoundation.store6.core + +/** + * A structured failure produced by a [Store] operation. + * + * The variant set is frozen for the 6.x major: new failure kinds map into these categories via + * their structured detail payloads, which lets the hierarchy bridge to an exhaustive Swift enum. + * Every message states what was attempted, for which key or namespace, and the likely fix. + */ +public sealed class StoreError { + /** Indicates that the configured fetcher failed to produce a value. */ + public class Fetch internal constructor( + /** What was attempted, for which key, and the likely fix. */ + public val message: String, + + /** The underlying failure, or `null` when no cause is available. */ + public val cause: Throwable?, + ) : StoreError() + + /** Indicates that a persistence operation against the store's durable state failed. */ + public class Persistence internal constructor( + /** What was attempted, for which key or namespace, and the likely fix. */ + public val message: String, + + /** The underlying failure, or `null` when no cause is available. */ + public val cause: Throwable?, + ) : StoreError() + + /** Indicates that a value could not be converted between representations. */ + public class Conversion internal constructor( + /** What was attempted, for which key, and the likely fix. */ + public val message: String, + + /** The underlying failure, or `null` when no cause is available. */ + public val cause: Throwable?, + ) : StoreError() + + /** Indicates that the requested [Freshness] policy could not be satisfied. */ + public class FreshnessUnsatisfiable internal constructor( + /** Which policy failed, for which key, and the likely fix. */ + public val message: String, + ) : StoreError() + + /** Indicates that a write conflicted with authoritative server state. */ + public class Conflict internal constructor( + /** Server-side metadata describing the conflicting state, when available. */ + public val serverMeta: StoreMeta?, + + /** What conflicted, for which key, and the likely fix. */ + public val message: String, + ) : StoreError() + + /** Indicates that no value exists for `key` and none could be produced. */ + public class Missing internal constructor( + /** The key for which no value exists. */ + public val key: StoreKey, + + /** Why the value is missing and the likely fix. */ + public val message: String, + ) : StoreError() +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreException.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreException.kt new file mode 100644 index 000000000..f47ab940a --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreException.kt @@ -0,0 +1,22 @@ +package org.mobilenativefoundation.store6.core + +/** + * Thrown by value-returning [Store] operations when no value can be returned. + * + * @param cause the underlying failure, or `null` when no cause is available + */ +public class StoreException internal constructor( + /** The structured store error represented by this exception. */ + public val error: StoreError, + cause: Throwable? = null, +) : RuntimeException(messageOf(error), cause) + +private fun messageOf(error: StoreError): String = + when (error) { + is StoreError.Fetch -> error.message + is StoreError.Persistence -> error.message + is StoreError.Conversion -> error.message + is StoreError.FreshnessUnsatisfiable -> error.message + is StoreError.Conflict -> error.message + is StoreError.Missing -> error.message + } diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreKey.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreKey.kt new file mode 100644 index 000000000..77d8e2c6f --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreKey.kt @@ -0,0 +1,25 @@ +package org.mobilenativefoundation.store6.core + +/** + * Identifies a value handled by a [Store]. + * + * A key's [namespace] and [canonicalId] together form its stable identity. + * Implementations should return the same identity components for the lifetime of the key. + */ +public interface StoreKey { + /** The logical key space containing this key. */ + public val namespace: StoreNamespace + + /** + * Returns the stable identifier for this key within [namespace]. + * + * @return an identifier that is unique within the key's namespace + */ + public fun canonicalId(): String +} + +/** A logical key space used to distinguish otherwise identical canonical identifiers. */ +public class StoreNamespace( + /** The stable name of this namespace. */ + public val value: String, +) diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreMeta.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreMeta.kt new file mode 100644 index 000000000..41d9a66c3 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreMeta.kt @@ -0,0 +1,16 @@ +package org.mobilenativefoundation.store6.core + +/** + * Typed freshness and identity metadata attached to stored values. + * + * [StoreResult.Data.age] and age-bounded [Freshness] policies derive from this metadata; an untyped + * metadata channel does not exist anywhere in Store. Milliseconds since the Unix epoch are used + * because no stable cross-platform instant type exists on the current language floor. + */ +public interface StoreMeta { + /** The wall-clock time at which the value was written, in Unix epoch milliseconds. */ + public val writtenAtEpochMillis: Long + + /** The optional entity tag associated with the value. */ + public val etag: String? +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreResult.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreResult.kt new file mode 100644 index 000000000..1a257b577 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreResult.kt @@ -0,0 +1,71 @@ +package org.mobilenativefoundation.store6.core + +import kotlin.time.Duration + +/** + * A state or value reported while observing a [Store]. + * + * @param V the type of data carried by [Data] + */ +public sealed interface StoreResult { + /** Indicates that the store has no servable resident value under the policy in effect. */ + public class Loading internal constructor() : StoreResult + + /** + * A value available from the store. + * + * @param V the value type + */ + public class Data internal constructor( + /** The value produced by the store. */ + public val value: V, + + /** The source from which `value` was obtained. */ + public val origin: Origin, + + /** Elapsed time since `value` was committed to the store. */ + public val age: Duration, + + /** + * Whether the value was invalidated, or exceeds the age bound of the freshness policy in + * effect for this observation. + */ + public val isStale: Boolean, + + /** Whether a fetch was in flight for this key when this result was emitted. */ + public val refreshing: Boolean, + ) : StoreResult + + /** + * Confirmation that the current value is still fresh without a new value being produced — + * the not-modified signal of a conditional fetch. + * + * Emitted when a conditional fetch returns not-modified: the value is server-confirmed fresh, + * metadata is refreshed, and `age` is the elapsed time since the last commit measured at + * revalidation. Revalidated is a lifecycle signal: `conflateLatestData` never conflates it + * away in favor of another kind; for a blocked collector a newer `Revalidated` supersedes an + * older queued one, so the kind itself is never lost. + */ + public class Revalidated internal constructor( + /** Elapsed time since the value was last committed, measured at revalidation. */ + public val age: Duration, + ) : StoreResult + + /** + * A retrieval failure; it terminates the observed stream only in the MustBeFresh initial + * cycle, otherwise the stream stays live. + */ + public class Error internal constructor( + /** The structured error describing the failure. */ + public val error: StoreError, + + /** + * Whether a stale value was served alongside this failure under a stale-tolerant policy. + * + * `true` when an invalidated resident was served and its refresh failed under + * [Freshness.CachedOrFetch] or [Freshness.StaleIfError]; `false` when no resident was + * served or the policy withheld it. + */ + public val servedStale: Boolean, + ) : StoreResult +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/Bookkeeper.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/Bookkeeper.kt new file mode 100644 index 000000000..7052bb20b --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/Bookkeeper.kt @@ -0,0 +1,218 @@ +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreMeta +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.core.seam.KeyStatus + +/** + * Engine-owned metadata with deliberate identity equality. + * + * A NotModified response creates a new instance so StateFlow re-emits even when the metadata + * values match the previous successful response. + */ +internal class EngineStoreMeta( + override val writtenAtEpochMillis: Long, + override val etag: String?, +) : StoreMeta + +/** + * Volatile in-memory [Bookkeeper] with one store-local sequence for successes, marks, and + * watermarks. + * + * This implementation only simulates durable staleness while clients share this instance. Its + * semantics require retaining the instance, its per-key records, and its namespace/global + * watermarks for the store lifetime; reconstructing it loses that history. A persistent + * implementation must durably retain the same information. + * + * The sequence supports [Long.MAX_VALUE] successful sequenced operations. A subsequent attempt + * fails before mutation instead of wrapping; exhaustion is a practical-lifetime invariant + * failure, not a recoverable storage failure. + */ +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +internal class InMemoryBookkeeper( + private val beforeMaintenancePublishTestGate: () -> Unit = {}, + initialSequence: Long = 0L, +) : Bookkeeper { + init { + require(initialSequence >= 0L) { "Bookkeeper sequence must not be negative" } + } + + private class Record( + val meta: StoreMeta?, + val lastSuccessSequence: Long?, + val lastFailureAtEpochMillis: Long?, + val consecutiveFailures: Int, + val staleSequence: Long?, + var cachedStatus: KeyStatus? = null, + ) + + private val lock = Mutex() + private var records = HashMap() + private var namespaceStaleWatermarks = HashMap() + private var globalStaleWatermark = 0L + private var sequence = initialSequence + private val watermarkOnlyStatus = + KeyStatus( + meta = null, + lastSuccessSequence = null, + lastFailureAtEpochMillis = null, + consecutiveFailures = 0, + durablyStale = true, + ) + + override suspend fun recordSuccess( + key: StoreKey, + meta: StoreMeta, + ) { + val keyId = KeyId.from(key) + lock.withLock { + val previous = records[keyId] + val nextSequence = nextSequenceOrThrow() + val nextRecord = + Record( + meta = meta, + lastSuccessSequence = nextSequence, + lastFailureAtEpochMillis = null, + consecutiveFailures = 0, + staleSequence = previous?.staleSequence, + ) + records[keyId] = nextRecord + sequence = nextSequence + } + } + + override suspend fun recordFailure( + key: StoreKey, + atEpochMillis: Long, + ) { + val keyId = KeyId.from(key) + lock.withLock { + val previous = records[keyId] + records[keyId] = + Record( + meta = previous?.meta, + lastSuccessSequence = previous?.lastSuccessSequence, + lastFailureAtEpochMillis = atEpochMillis, + consecutiveFailures = (previous?.consecutiveFailures ?: 0) + 1, + staleSequence = previous?.staleSequence, + ) + } + } + + override suspend fun status(key: StoreKey): KeyStatus? { + val keyId = KeyId.from(key) + return lock.withLock { + val record = records[keyId] + val coveringStaleSequence = + maxOf( + record?.staleSequence ?: 0L, + namespaceStaleWatermarks[keyId.namespace] ?: 0L, + globalStaleWatermark, + ) + if (record == null && coveringStaleSequence == 0L) { + null + } else if (record == null) { + watermarkOnlyStatus + } else { + val durablyStale = + coveringStaleSequence > (record.lastSuccessSequence ?: 0L) + record.cachedStatus + ?.takeIf { cached -> cached.durablyStale == durablyStale } + ?: KeyStatus( + meta = record.meta, + lastSuccessSequence = record.lastSuccessSequence, + lastFailureAtEpochMillis = record.lastFailureAtEpochMillis, + consecutiveFailures = record.consecutiveFailures, + durablyStale = durablyStale, + ).also { status -> + record.cachedStatus = status + } + } + } + } + + override suspend fun forget(key: StoreKey) { + val keyId = KeyId.from(key) + lock.withLock { + records.remove(keyId) + } + } + + override suspend fun markStale(key: StoreKey) { + val keyId = KeyId.from(key) + lock.withLock { + val previous = records[keyId] + val nextSequence = nextSequenceOrThrow() + val stagedRecords = copyRecords() + stagedRecords[keyId] = + Record( + meta = previous?.meta, + lastSuccessSequence = previous?.lastSuccessSequence, + lastFailureAtEpochMillis = previous?.lastFailureAtEpochMillis, + consecutiveFailures = previous?.consecutiveFailures ?: 0, + staleSequence = nextSequence, + ) + beforeMaintenancePublishTestGate() + records = stagedRecords + sequence = nextSequence + } + } + + override suspend fun advanceStaleWatermark(namespace: StoreNamespace) { + lock.withLock { + val nextSequence = nextSequenceOrThrow() + val stagedWatermarks = + HashMap(namespaceStaleWatermarks.size).also { staged -> + staged.putAll(namespaceStaleWatermarks) + staged[namespace.value] = nextSequence + } + beforeMaintenancePublishTestGate() + namespaceStaleWatermarks = stagedWatermarks + sequence = nextSequence + } + } + + override suspend fun advanceGlobalStaleWatermark() { + lock.withLock { + val nextSequence = nextSequenceOrThrow() + beforeMaintenancePublishTestGate() + globalStaleWatermark = nextSequence + sequence = nextSequence + } + } + + override suspend fun forgetNamespace(namespace: StoreNamespace) { + lock.withLock { + val stagedRecords = HashMap(records.size) + records.forEach { (key, record) -> + if (key.namespace != namespace.value) { + stagedRecords[key] = record + } + } + beforeMaintenancePublishTestGate() + records = stagedRecords + } + } + + override suspend fun forgetAll() { + lock.withLock { + val stagedRecords = HashMap() + beforeMaintenancePublishTestGate() + records = stagedRecords + } + } + + private fun copyRecords(): HashMap = + HashMap(records.size).also { staged -> staged.putAll(records) } + + private fun nextSequenceOrThrow(): Long { + check(sequence < Long.MAX_VALUE) { "Bookkeeper sequence exhausted" } + return sequence + 1L + } +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/EngineResidency.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/EngineResidency.kt new file mode 100644 index 000000000..b20615b62 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/EngineResidency.kt @@ -0,0 +1,24 @@ +package org.mobilenativefoundation.store6.core.internal + +/** + * Residency callbacks a [KeyEngine] uses so engine-owned background work pins its own residency. + * + * The only engine-owned work that outlives its caller's registry reference is the fetch job + * (fetch survives waiter cancellation). The job retains one reference as its first act and + * releases it after settlement, so an engine with an in-flight fetch is unevictable by + * construction and its release is the trigger for the registry's quiescence check. + */ +internal interface EngineResidencyHooks { + /** Retains one residency reference; the engine is active because the launcher holds a ref. */ + suspend fun retainFetchRef() + + /** Releases the fetch reference and runs the idle/eviction check at zero references. */ + suspend fun releaseFetchRef() + + /** No-op hooks for direct engine tests that construct a [KeyEngine] without a registry. */ + object Noop : EngineResidencyHooks { + override suspend fun retainFetchRef() = Unit + + override suspend fun releaseFetchRef() = Unit + } +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/FetchSlot.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/FetchSlot.kt new file mode 100644 index 000000000..ccbaa18b5 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/FetchSlot.kt @@ -0,0 +1,139 @@ +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.MutableStateFlow +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreException +import org.mobilenativefoundation.store6.core.seam.KeyStatus +import kotlin.time.Duration + +/** Describes whether a key currently owns a fetch operation. */ +internal sealed interface FetchSlot { + /** No fetch is active for the key. */ + data object Idle : FetchSlot + + /** A fetch is active and represented by [ticket]. */ + class InFlight( + val ticket: FetchTicket, + + /** The key's clear epoch when this fetch launched; a later clear supersedes the commit. */ + val clearEpochAtLaunch: Long, + ) : FetchSlot +} + +/** + * Identifies one fetch attempt and exposes its one-shot completion to all waiters. + * + * [outcome] reaches a terminal state either when the owning coroutine publishes its result or + * when the parent engine begins cancellation. + */ +@OptIn(ExperimentalStoreApi::class) +internal class FetchTicket( + val outcome: CompletableDeferred, + + /** Residence revision whose demand this ticket reserved, including an absent revision. */ + val requestRevision: Long = 0L, + + /** Residence revision used as the baseline for a NotModified response, when one existed. */ + val residenceRevisionAtLaunch: Long? = null, + + /** Exact resident envelope captured with the nullable NotModified baseline. */ + val residenceEnvelopeAtLaunch: Any? = null, + + /** Stale epoch used to plan this ticket's pre-fetch baseline. */ + val staleEpochAtLaunch: Long = 0L, + + /** Wall-clock instant used to plan this ticket's pre-fetch baseline. */ + val nowEpochMillisAtLaunch: Long = 0L, + + /** Durable bookkeeping posture used to plan this ticket's pre-fetch baseline. */ + val statusAtLaunch: KeyStatus? = null, +) { + /** + * KMP-safe early classification published with slot settlement, before ordered outcome tails. + * Reader delivery uses it to preserve ticket ownership and wake a durable causal commit row; + * [outcome] remains authoritative. + */ + val disposition = MutableStateFlow(FetchDisposition.InFlight) +} + +/** Slot-settled ticket identity visible before persistence/bookkeeping tails complete. */ +internal sealed interface FetchDisposition { + data object InFlight : FetchDisposition + + class Committing( + val attribution: AttributionTag, + /** Completed-write sequence visible before this write returned from persistence. */ + val successfulWriteSequenceAtStart: Long, + ) : FetchDisposition + + class Committed( + val successfulWriteSequence: Long, + val attribution: AttributionTag, + /** Reader generation whose raw observations were closed by this commit. */ + val rawReaderGen: Long, + /** Latest raw observation ordered before the durable commit boundary. */ + val rawCommitCutoff: Long, + /** Exact pre-return raw observation authorized after convergence, when one exists. */ + val authoritativeRawSequence: Long?, + ) : FetchDisposition + + class Revalidated( + val envelope: Any, + ) : FetchDisposition + + data object Deleted : FetchDisposition + + data object Failed : FetchDisposition + + data object Cancelled : FetchDisposition + + data object ObsoleteRevalidation : FetchDisposition + + data object Superseded : FetchDisposition +} + +/** The terminal result of a fetch attempt. */ +internal sealed interface FetchOutcome { + /** The fetched value was committed before the outcome became observable. */ + class Committed( + val value: Any, + /** Successful SoT-write sequence that became current before this outcome was published. */ + val successfulWriteSequence: Long, + /** Exact tag stamped for this commit, used to classify observations made during write. */ + val attribution: AttributionTag, + /** Reader generation whose raw observations were closed by this commit. */ + val rawReaderGen: Long, + /** Latest raw observation ordered before the durable commit boundary. */ + val rawCommitCutoff: Long, + /** Exact pre-return raw observation authorized after convergence, when one exists. */ + val authoritativeRawSequence: Long?, + ) : FetchOutcome + + /** The fetch failed with [exception] at [atEpochMillis]. */ + class Failed( + val exception: StoreException, + val atEpochMillis: Long, + val bookkeepingRecorded: Boolean = false, + ) : FetchOutcome + + /** The fetch succeeded but a clear advanced the clear epoch after launch; the value was discarded. */ + data object Superseded : FetchOutcome + + /** The fetcher reported not-modified; resident metadata was refreshed in place. */ + class Revalidated( + val residenceRevision: Long, + /** Exact refreshed envelope; same-value reader replays preserve this identity. */ + val envelope: Any, + /** Elapsed time since the value's previous commit, measured at revalidation. */ + val age: Duration, + ) : FetchOutcome + + /** A NotModified baseline changed before commit and must be planned again. */ + data object ObsoleteRevalidation : FetchOutcome + + /** The fetcher reported server-side deletion; [residenceRevision] names exact absence. */ + class Deleted( + val residenceRevision: Long, + ) : FetchOutcome +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/FetcherAdapters.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/FetcherAdapters.kt new file mode 100644 index 000000000..3a8d33ba4 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/FetcherAdapters.kt @@ -0,0 +1,29 @@ +package org.mobilenativefoundation.store6.core.internal + +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.seam.Fetcher +import org.mobilenativefoundation.store6.core.seam.FetcherResult + +/** Adapts the public success-or-throw lambda sugar to the regular [Fetcher] interface. */ +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +internal class LambdaFetcher( + private val fetch: suspend (K) -> V, +) : Fetcher { + override suspend fun fetch( + key: K, + etag: String?, + ): FetcherResult = FetcherResult.Success(fetch(key)) +} + +/** Adapts the public rich-result lambda sugar to the regular [Fetcher] interface. */ +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +internal class ResultFetcher( + private val fetch: suspend (K) -> FetcherResult, +) : Fetcher { + override suspend fun fetch( + key: K, + etag: String?, + ): FetcherResult = fetch(key) +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/FreshnessValidator.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/FreshnessValidator.kt new file mode 100644 index 000000000..2ba32b6ba --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/FreshnessValidator.kt @@ -0,0 +1,95 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.core.internal + +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.StoreMeta +import org.mobilenativefoundation.store6.core.seam.FetchPlan +import org.mobilenativefoundation.store6.core.seam.FreshnessContext +import org.mobilenativefoundation.store6.core.seam.FreshnessValidator +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +internal val FetchPlan.servesResident: Boolean + get() = + when (this) { + FetchPlan.Skip -> true + is FetchPlan.Fetch -> servesResidentWhileFetching + is FetchPlan.Conditional -> servesResidentWhileFetching + } + +/** + * Returns elapsed age with the wall-clock posture: missing metadata and backward clocks + * are zero, while positive subtraction overflow saturates to [Long.MAX_VALUE] milliseconds. + */ +internal fun elapsedAge( + nowEpochMillis: Long, + meta: StoreMeta?, +): Duration { + if (meta == null) return Duration.ZERO + val writtenAtEpochMillis = meta.writtenAtEpochMillis + val elapsedMillis = + if (nowEpochMillis <= writtenAtEpochMillis) { + 0L + } else { + val delta = nowEpochMillis - writtenAtEpochMillis + if (delta < 0L) Long.MAX_VALUE else delta + } + return elapsedMillis.milliseconds +} + +/** + * The zero-configuration policy table. + * + * Negative wall-clock deltas are clamped to zero before evaluating [Freshness.MaxAge]. + */ +@OptIn(DelicateStoreApi::class) +internal object DefaultFreshnessValidator : FreshnessValidator { + override fun plan(context: FreshnessContext): FetchPlan = + when (val freshness = context.freshness) { + Freshness.LocalOnly -> FetchPlan.Skip + Freshness.MustBeFresh -> + context.fetchPlan(servesResidentWhileFetching = false) + Freshness.CachedOrFetch, + Freshness.StaleIfError, + -> context.cachedOrFetchPlan() + + is Freshness.MaxAge -> context.maxAgePlan(freshness) + } + + private fun FreshnessContext.cachedOrFetchPlan(): FetchPlan = + if ( + !hasResidentValue || + meta == null || + epochStale || + status?.durablyStale == true + ) { + fetchPlan(servesResidentWhileFetching = hasResidentValue) + } else { + FetchPlan.Skip + } + + private fun FreshnessContext.maxAgePlan(freshness: Freshness.MaxAge): FetchPlan { + if (!hasResidentValue) { + return fetchPlan(servesResidentWhileFetching = false) + } + + val overAge = meta == null || elapsedAge(nowEpochMillis, meta) > freshness.notOlderThan + + return if (epochStale || status?.durablyStale == true || overAge) { + fetchPlan(servesResidentWhileFetching = false) + } else { + FetchPlan.Skip + } + } + + private fun FreshnessContext.fetchPlan(servesResidentWhileFetching: Boolean): FetchPlan { + val etag = meta?.etag + return if (hasResidentValue && etag != null) { + FetchPlan.Conditional(etag, servesResidentWhileFetching) + } else { + FetchPlan.Fetch(servesResidentWhileFetching) + } + } +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/InMemorySourceOfTruth.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/InMemorySourceOfTruth.kt new file mode 100644 index 000000000..9f3402b49 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/InMemorySourceOfTruth.kt @@ -0,0 +1,90 @@ +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth + +/** + * DSL-default source of truth that keeps the engine on one persistence path with or without a + * caller-supplied implementation. + * + * Canonical-key cells are intentionally unbounded. + */ +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +internal class InMemorySourceOfTruth : SourceOfTruth { + private class Cell( + val row: V?, + val version: Long, + ) + + private val lock = Mutex() + private val cells = HashMap>>() + + override fun reader(key: K): Flow = + flow { + emitAll(cellFor(key).map { it.row }) + } + + override suspend fun write( + key: K, + value: V, + ) { + update(key, value) + } + + override suspend fun delete(key: K) { + update(key, null) + } + + override suspend fun deleteNamespace(namespace: StoreNamespace) { + lock.withLock { + cells.forEach { (keyId, cell) -> + if (keyId.namespace == namespace.value) { + cell.emitNull() + } + } + } + } + + override suspend fun deleteAll() { + lock.withLock { + cells.values.forEach { cell -> cell.emitNull() } + } + } + + private suspend fun cellFor(key: K): MutableStateFlow> { + val keyId = KeyId.from(key) + return lock.withLock { cellFor(keyId) } + } + + private suspend fun update( + key: K, + row: V?, + ) { + val keyId = KeyId.from(key) + lock.withLock { + val cell = cellFor(keyId) + val current = cell.value + cell.value = Cell(row = row, version = current.version + 1L) + } + } + + private fun cellFor(keyId: KeyId): MutableStateFlow> = + cells.getOrPut(keyId) { + MutableStateFlow(Cell(row = null, version = 0L)) + } + + private fun MutableStateFlow>.emitNull() { + val current = value + value = Cell(row = null, version = current.version + 1L) + } +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyEngine.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyEngine.kt new file mode 100644 index 000000000..7c894c6c7 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyEngine.kt @@ -0,0 +1,5166 @@ +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.ProducerScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.retryWhen +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreException +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreMeta +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.core.seam.FetchPlan +import org.mobilenativefoundation.store6.core.seam.Fetcher +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import org.mobilenativefoundation.store6.core.seam.FreshnessContext +import org.mobilenativefoundation.store6.core.seam.FreshnessValidator +import org.mobilenativefoundation.store6.core.seam.KeyEvents +import org.mobilenativefoundation.store6.core.seam.KeyStatus +import org.mobilenativefoundation.store6.core.seam.Overlay +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import org.mobilenativefoundation.store6.core.seam.StoreTelemetry +import org.mobilenativefoundation.store6.core.seam.WallClock +import kotlin.time.Duration +import kotlin.time.TimeMark +import kotlin.time.TimeSource + +/** Emits every stale epoch that advanced beyond the snapshot used to plan a stream startup. */ +internal fun Flow.staleEpochsAfter(planningEpoch: Long): Flow = + map { it.staleEpoch } + .distinctUntilChanged() + .filter { observedEpoch -> observedEpoch > planningEpoch } + +/** + * Coordinates one canonical key around a single shared source-of-truth reader pipeline. + * + * [writeLock] serializes persistence mutations and ordered bookkeeping. [stateLock] protects the + * immutable state snapshot, residence, and its monotone revision. When both locks are needed the + * order is always writeLock then stateLock; no lock is held across fetcher I/O. + */ +@OptIn(ExperimentalStoreApi::class, ExperimentalCoroutinesApi::class) +internal class KeyEngine( + internal val key: K, + private val keyId: KeyId, + private val fetcher: Fetcher, + private val sot: SourceOfTruth, + private val bookkeeper: Bookkeeper, + private val validator: FreshnessValidator, + private val wallClock: WallClock, + private val engineScope: CoroutineScope, + private val residencyHooks: EngineResidencyHooks = EngineResidencyHooks.Noop, + /** Optional deterministic gate used only by direct engine tests before final initial recapture. */ + private val beforeInitialDeliveryTestGate: suspend () -> Unit = {}, + /** Optional direct-test gate after the initial planning snapshot, before outcome classification. */ + private val afterInitialPlanningSnapshotTestGate: suspend () -> Unit = {}, + /** Optional deterministic gate used only by direct engine tests after raw reader observation. */ + private val beforeReaderRecordMappingTestGate: suspend () -> Unit = {}, + /** Optional direct-test gate after mapping but before serialized reader delivery. */ + private val beforeReaderDeliveryLockTestGate: suspend (ReaderRecord) -> Unit = {}, + /** Optional deterministic gate used only by direct engine tests inside serialized delivery. */ + private val beforeReaderDeliveryTestGate: suspend () -> Unit = {}, + /** Optional deterministic gate used only by direct engine tests before outcome delivery. */ + private val beforeTicketOutcomeDeliveryTestGate: suspend () -> Unit = {}, + /** Optional deterministic gate before first classification of a replacement disposition. */ + private val beforeReplacementDispositionClassificationTestGate: suspend () -> Unit = {}, + /** Store-local fence shared by RealStore; direct tests receive an isolated coordinator. */ + private val maintenanceCoordinator: MaintenanceCoordinator = MaintenanceCoordinator(), + /** Null keeps every telemetry hook and fetch-duration allocation off the unconfigured path. */ + private val telemetry: StoreTelemetry? = null, + /** Store-level advisory bus; direct engine tests receive an isolated equivalent by default. */ + private val events: MutableSharedFlow = + MutableSharedFlow( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ), + /** Null preserves the direct-residence path without projection allocations. */ + private val overlay: Overlay? = null, + /** Deterministic direct-test gate after Pending and before invoking Overlay.apply. */ + private val beforeProjectionApplyTestGate: suspend (V?) -> Unit = {}, + /** Deterministic direct-test gate after Overlay.apply and before the commit recheck. */ + private val afterProjectionApplyTestGate: suspend (V?) -> Unit = {}, + /** Deterministic direct-test gate before serialized projection snapshot delivery. */ + private val beforeProjectionDeliveryLockTestGate: suspend () -> Unit = {}, + /** Deterministic direct-test gate inside serialized projection snapshot delivery. */ + private val beforeProjectionDeliveryTestGate: suspend () -> Unit = {}, + /** Deterministic direct-test gate after serialized projection snapshot delivery. */ + private val afterProjectionDeliveryTestGate: suspend () -> Unit = {}, + /** Deterministic direct-test gate before a readiness waiter suspends on both state flows. */ + private val beforeProjectionReadinessWaitTestGate: suspend () -> Unit = {}, + /** Deterministic direct-test gate after coherent base capture and before authorization. */ + private val beforeProjectionAuthorizationTestGate: suspend () -> Unit = {}, +) { + private val stateLock = Mutex() + private val writeLock = Mutex() + private val engineJob: Job = checkNotNull(engineScope.coroutineContext[Job]) + private val closeSignal: Job = Job(engineJob) + + private val mutableState = MutableStateFlow(KeyState.Initial) + internal val state: StateFlow = mutableState.asStateFlow() + + private val residence = MutableStateFlow?>(null) + + /** Changed only by [replaceResidenceLocked] while stateLock is held. */ + private var residenceRevision: Long = 0L + + /** Exists only for configured engines and immediately obsoletes revision-bound waiters. */ + private val projectionResidence: MutableStateFlow>? = + overlay?.let { + MutableStateFlow( + ProjectionResidence( + ProjectionBase( + envelope = null, + revision = residenceRevision, + ), + ), + ) + } + + /** Latest single-writer state; null is the allocation-free unconfigured path. */ + private val projectionSnapshot: MutableStateFlow>? = + overlay?.let { MutableStateFlow(ProjectionSnapshot.Uninitialized) } + + /** Advanced only by the projection writer while publishing Pending or Terminal. */ + private var projectionGeneration: Long = 0L + + /** Lock-serialized source-order boundary for raw observations and active SoT writes. */ + private val writeObservationBoundary = + MutableStateFlow( + WriteObservationBoundary( + readerGen = 0L, + observedAttribution = null, + activeAttribution = null, + successfulSequence = 0L, + latestRawSequence = 0L, + activeRawPhase = ActiveRawPhase.Unobserved, + readerSession = 0L, + readerSessionActive = false, + pendingWriteAttribution = null, + ), + ) + + /** Latest durable resolution of raw observations ordered before a successful write return. */ + private var rawCommitResolution: RawCommitResolution? = null + + /** Completion fence for a destructive persistence mutation; guarded by [stateLock]. */ + private var destructiveMutationBarrier: CompletableDeferred? = null + + /** + * One retrying adapter pipeline shared by every active collector for this key. + * + * Adapter invocation and collection failures are converted before engine mapping. A mapping + * or transition defect therefore remains fatal instead of being mislabeled and retried as a + * persistence outage. + */ + private val readerRecords: SharedFlow> = + state + .map { it.readerGen } + .distinctUntilChanged() + .flatMapLatest { readerGen -> + var failureReportedForEpisode = false + flow { + val readerSession = beginRawReaderSession(readerGen) + try { + emitAll(sot.reader(key)) + } finally { + endRawReaderSession(readerGen, readerSession) + } + } + .map> { value -> + failureReportedForEpisode = false + try { + rawReaderRow(readerGen, value) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (failure: Throwable) { + throw RawObservationFailure(failure) + } + } + .conflate() + .onCompletion { cause -> + if (cause == null) { + error( + "SourceOfTruth.reader completed normally for key " + + "'${keyId.namespace}/${keyId.canonicalId}'.", + ) + } + } + .retryWhen { failure, _ -> + if (failure is CancellationException) throw failure + if (failure is RawObservationFailure) throw failure.engineFailure + if (!failureReportedForEpisode) { + emit(RawReaderEvent.Failure(readerException(failure))) + failureReportedForEpisode = true + } + delay(READER_RETRY_DELAY_MILLIS) + true + } + .mapNotNull { event -> + when (event) { + is RawReaderEvent.Row -> { + beforeReaderRecordMappingTestGate() + toRecord(readerGen, event) + } + is RawReaderEvent.Failure -> + readerFailureRecord(readerGen, event.exception) + } + } + .retryWhen { failure, _ -> + if (failure is RestartRawReaderSession) { + true + } else { + throw failure + } + } + } + .shareIn( + scope = engineScope, + started = + SharingStarted.WhileSubscribed( + stopTimeoutMillis = READER_PIPELINE_GRACE_MILLIS, + replayExpirationMillis = 0L, + ), + replay = 1, + ) + + init { + overlay?.let { configured -> + engineScope.launch { runProjectionWriter(configured) } + } + } + + /** Quiescent for idling: no fetch owns the slot. All other work holds registry references. */ + internal fun isQuiescentForIdle(): Boolean = state.value.fetch is FetchSlot.Idle + + /** Destroys a quiescent, unreferenced engine; nothing user-visible is running by definition. */ + internal fun destroy() { + engineScope.cancel(CancellationException(ENGINE_EVICTED_MESSAGE)) + } + + /** Assigns residence and advances its revision for every accepted observation or mutation. */ + private fun replaceResidenceLocked( + envelope: ValueEnvelope?, + preserveProjectionAuthorizationLineage: Boolean = false, + ): Long { + val nextProjectionAuthorizationLineage = + projectionResidence?.let { projection -> + when { + envelope == null -> null + preserveProjectionAuthorizationLineage -> + checkNotNull(projection.value.base.authorizationLineage) { + "A metadata successor requires an existing projection lineage." + } + else -> ProjectionAuthorizationLineage() + } + } + residence.value = envelope + residenceRevision += 1L + projectionResidence?.value = + ProjectionResidence( + ProjectionBase( + envelope = envelope, + revision = residenceRevision, + authorizationLineage = nextProjectionAuthorizationLineage, + ), + ) + return residenceRevision + } + + /** Returns the configured projection base only when it names this exact residence snapshot. */ + private fun projectionBaseLocked( + envelope: ValueEnvelope?, + revision: Long, + ): ProjectionBase? = + projectionResidence?.value?.base?.takeIf { base -> + base.envelope === envelope && base.revision == revision + } + + /** Serially accepts residence and overlay triggers and computes outside every Store lock. */ + private suspend fun runProjectionWriter(configured: Overlay) { + val residenceTriggers = checkNotNull(projectionResidence).map { Unit } + val overlayTriggers = + configured.changes + .filter { changed -> KeyId.from(changed) == keyId } + .map { Unit } + .catch { failure -> + // Downstream/parent cancellation stays transparent, preserving an apply failure. + if (failure is CancellationException && engineJob.isActive) { + throw ProjectionChangesFailure(failure) + } + throw failure + } + try { + merge(residenceTriggers, overlayTriggers).collect { + val pending = + stateLock.withLock { + val base = checkNotNull(projectionResidence).value.base + projectionGeneration += 1L + ProjectionSnapshot.Pending( + base = base, + generation = projectionGeneration, + ).also { projection -> + checkNotNull(projectionSnapshot).value = projection + } + } + val baseValue = pending.base.envelope?.value + beforeProjectionApplyTestGate(baseValue) + val output = configured.apply(key, baseValue) + afterProjectionApplyTestGate(baseValue) + val projection = + when { + pending.base.envelope != null && + output == pending.base.envelope.value -> + Projection.Value(pending.base.envelope) + + output == null -> Projection.Absent + else -> Projection.Overlaid(output) + } + stateLock.withLock { + val currentResidence = checkNotNull(projectionResidence).value.base + val currentSnapshot = checkNotNull(projectionSnapshot).value + if ( + currentResidence.matches(pending.base) && + currentSnapshot is ProjectionSnapshot.Pending && + currentSnapshot.generation == pending.generation + ) { + projectionSnapshot.value = + ProjectionSnapshot.Ready( + base = pending.base, + generation = pending.generation, + projection = projection, + ) + } + } + } + } catch (failure: Throwable) { + if (failure is CancellationException && !engineJob.isActive) throw failure + val terminalFailure = + (failure as? ProjectionChangesFailure)?.projectionCause ?: failure + stateLock.withLock { + projectionGeneration += 1L + checkNotNull(projectionSnapshot).value = + ProjectionSnapshot.Terminal( + generation = projectionGeneration, + failure = terminalFailure, + ) + } + } + } + + /** Opens one upstream reader session and retires any fence from a cancelled predecessor. */ + private fun beginRawReaderSession(readerGen: Long): Long { + val opened = + updateWriteObservationBoundary { current -> + if (current.readerGen != readerGen) { + current + } else { + val nextSession = current.readerSession + 1L + current.copy( + readerSession = nextSession, + readerSessionActive = true, + pendingWriteAttribution = null, + ) + } + } + return opened.readerSession + } + + /** Retires only the matching session; a newer reader must keep its own boundary state. */ + private fun endRawReaderSession( + readerGen: Long, + readerSession: Long, + ) { + updateWriteObservationBoundary { current -> + if (current.readerGen == readerGen && current.readerSession == readerSession) { + current.copy( + readerSessionActive = false, + pendingWriteAttribution = null, + ) + } else { + current + } + } + } + + /** Captures source order and active-write provenance under [stateLock] before conflation. */ + private suspend fun rawReaderRow( + readerGen: Long, + value: V?, + ): RawReaderEvent.Row = + stateLock.withLock { captureRawReaderRowLocked(readerGen, value) } + + /** Allocates one raw token; the return-boundary CAS may race but mapping cannot. */ + private fun captureRawReaderRowLocked( + readerGen: Long, + value: V?, + ): RawReaderEvent.Row { + while (true) { + val current = writeObservationBoundary.value + if (current.readerGen != readerGen) { + return RawReaderEvent.Row( + value = value, + readerGen = readerGen, + rawObservationSequence = current.latestRawSequence, + attributionAtObservation = current.observedAttribution, + successfulWriteSequenceAtObservation = current.successfulSequence, + activeWriteAttributionAtObservation = current.activeAttribution, + followedMatchingActiveWriteRow = false, + pendingCommitFenceAtObservation = false, + ) + } + + val nextSequence = current.latestRawSequence + 1L + // A live reader can have pre-return notifications queued upstream. The exact + // writer-current closes that fence; observations after it are later authority. + val pendingWriteAttribution = current.pendingWriteAttribution + val activeWriteAttribution = current.activeAttribution + val activeAttributionAtObservation = + when { + pendingWriteAttribution == null -> activeWriteAttribution + value != null && pendingWriteAttribution.value == value -> + pendingWriteAttribution + value != null && activeWriteAttribution?.value == value -> + activeWriteAttribution + else -> pendingWriteAttribution + } + val observation = + RawWriteObservation( + readerGen = readerGen, + rawSequence = nextSequence, + value = value, + attributionAtObservation = current.observedAttribution, + activeWriteAttributionAtObservation = activeAttributionAtObservation, + successfulWriteSequenceAtObservation = current.successfulSequence, + ) + val activeObservation = + if (activeAttributionAtObservation === activeWriteAttribution) { + observation + } else { + observation.copy( + activeWriteAttributionAtObservation = activeWriteAttribution, + ) + } + val matchingActiveAttribution = activeObservation.matchingWriterAttribution() + val followedMatchingActiveWriteRow = + activeWriteAttribution != null && + matchingActiveAttribution == null && + (current.activeRawPhase is ActiveRawPhase.Matching || + current.activeRawPhase is ActiveRawPhase.OtherAfterMatching) + val nextPhase = + if (activeWriteAttribution == null) { + current.activeRawPhase + } else if (matchingActiveAttribution != null) { + ActiveRawPhase.Matching(activeObservation, matchingActiveAttribution) + } else { + when (current.activeRawPhase) { + is ActiveRawPhase.Matching -> + ActiveRawPhase.OtherAfterMatching( + matchingObservation = current.activeRawPhase.observation, + observation = activeObservation, + ) + + is ActiveRawPhase.OtherAfterMatching -> + ActiveRawPhase.OtherAfterMatching( + matchingObservation = + current.activeRawPhase.matchingObservation, + observation = activeObservation, + ) + + ActiveRawPhase.Unobserved, + is ActiveRawPhase.OtherBeforeMatching, + -> ActiveRawPhase.OtherBeforeMatching(activeObservation) + } + } + val activeExactSupersedesPending = + value != null && + activeWriteAttribution != null && + activeWriteAttribution.value == value + val updated = + current.copy( + latestRawSequence = nextSequence, + activeRawPhase = nextPhase, + pendingWriteAttribution = + if ( + value != null && + (pendingWriteAttribution?.value == value || + activeExactSupersedesPending) + ) { + null + } else { + pendingWriteAttribution + }, + ) + if (writeObservationBoundary.compareAndSet(current, updated)) { + return RawReaderEvent.Row( + value = value, + readerGen = readerGen, + rawObservationSequence = nextSequence, + attributionAtObservation = observation.attributionAtObservation, + successfulWriteSequenceAtObservation = + observation.successfulWriteSequenceAtObservation, + activeWriteAttributionAtObservation = + observation.activeWriteAttributionAtObservation, + followedMatchingActiveWriteRow = followedMatchingActiveWriteRow, + pendingCommitFenceAtObservation = + pendingWriteAttribution != null && + activeAttributionAtObservation === pendingWriteAttribution, + ) + } + } + } + + /** Mirrors lock-owned state into the source-order boundary while [stateLock] is held. */ + private fun syncObservedAttributionLocked(state: KeyState) { + val generationChanged = writeObservationBoundary.value.readerGen != state.readerGen + if (generationChanged) { + rawCommitResolution = null + } + updateWriteObservationBoundary { current -> + if (current.readerGen == state.readerGen) { + current.copy(observedAttribution = state.attribution) + } else { + current.copy( + readerGen = state.readerGen, + observedAttribution = state.attribution, + activeAttribution = null, + activeRawPhase = ActiveRawPhase.Unobserved, + readerSessionActive = false, + pendingWriteAttribution = null, + ) + } + } + } + + /** Applies one CAS-loop boundary update and preserves concurrent raw observations. */ + private inline fun updateWriteObservationBoundary( + transform: (WriteObservationBoundary) -> WriteObservationBoundary, + ): WriteObservationBoundary { + while (true) { + val current = writeObservationBoundary.value + val updated = transform(current) + if (writeObservationBoundary.compareAndSet(current, updated)) return updated + } + } + + /** Applies one pure event while serializing the state swap. */ + private suspend fun applyEvent(event: KeyEvent): KeyEffect = + stateLock.withLock { + val result = transition(mutableState.value, event) + mutableState.value = result.state + syncObservedAttributionLocked(result.state) + result.effect + } + + /** Maps a reader row only after any exact writer attribution becomes durably committed. */ + private suspend fun toRecord( + readerGen: Long, + event: RawReaderEvent.Row, + ): ReaderRecord? { + var prepared: PreparedReaderRow? = null + var decided = false + val immediate = + stateLock.withLock { + val snapshot = mutableState.value + if (snapshot.readerGen != readerGen) return@withLock null + if (isSupersededRawObservation(event)) { + decided = true + return@withLock null + } + rawCommitResolution + ?.takeIf { + it.readerGen == readerGen && + event.rawObservationSequence <= it.rawCommitCutoff + } + ?.let { resolution -> + decided = true + return@withLock if ( + event.rawObservationSequence == + resolution.authoritativeRawSequence + ) { + recordFromConvergedRawLocked(event, resolution) + ?: recordForConfirmFreshAdvancedEnvelopeLocked( + event = event, + resolution = resolution, + consumedAttributionOverride = null, + ) + } else { + null + } + } + // A fenced mismatch is ambiguous with a notification queued before the + // writer-current. It cannot map directly; a committed owner replaces the reader + // so that only the new session's current row can establish later authority. + if (event.pendingCommitFenceAtObservation) { + val ownerAttribution = + checkNotNull(event.activeWriteAttributionAtObservation) + val matchingAttribution = + ownerAttribution.takeIf { + event.value != null && it.value == event.value + } + when (val disposition = ownerAttribution.owner.disposition.value) { + is FetchDisposition.Committed -> { + decided = true + return@withLock if ( + matchingAttribution != null && + disposition.attribution === ownerAttribution + ) { + recordForExactWriterEnvelopeLocked( + event = event, + attribution = ownerAttribution, + consumedAttribution = null, + ) ?: recordForCurrentSameValueEnvelopeLocked( + event = event, + consumedAttribution = null, + ) + } else { + if ( + matchingAttribution == null && + disposition.attribution === ownerAttribution && + pendingFenceStillActiveLocked(event, ownerAttribution) + ) { + throw RestartRawReaderSession() + } + null + } + } + + FetchDisposition.InFlight, + is FetchDisposition.Committing, + -> { + prepared = + PreparedReaderRow( + consumedAttribution = null, + ownerAttribution = ownerAttribution, + matchingAttribution = matchingAttribution, + dropNonmatchingOnCommit = true, + ) + return@withLock null + } + + else -> { + decided = true + return@withLock null + } + } + } + + val consumed = + transition( + snapshot, + KeyEvent.ConsumeAttribution(event.attributionAtObservation), + ) + mutableState.value = consumed.state + syncObservedAttributionLocked(consumed.state) + val tag = (consumed.effect as KeyEffect.Consumed).tag + val value = event.value + val matchingAttribution = + value?.let { + when { + tag?.value == value -> tag + tag == null && + event.activeWriteAttributionAtObservation?.value == value -> + event.activeWriteAttributionAtObservation + else -> null + } + } + val activeAttribution = event.activeWriteAttributionAtObservation + val activeDisposition = activeAttribution?.owner?.disposition?.value + val postReturnOrPostMatchProvisional = + matchingAttribution == null && + activeDisposition is FetchDisposition.Committing && + activeDisposition.attribution === activeAttribution && + (event.successfulWriteSequenceAtObservation > + activeDisposition.successfulWriteSequenceAtStart || + event.followedMatchingActiveWriteRow) + if (postReturnOrPostMatchProvisional) { + prepared = + PreparedReaderRow( + consumedAttribution = tag, + ownerAttribution = checkNotNull(activeAttribution), + matchingAttribution = null, + dropNonmatchingOnCommit = false, + ) + null + } else if (matchingAttribution == null) { + decided = true + mapReaderRowLocked( + readerGen = readerGen, + event = event, + tag = tag, + matchingAttribution = null, + ) + } else { + when (val disposition = matchingAttribution.owner.disposition.value) { + is FetchDisposition.Committed -> { + decided = true + if (disposition.attribution !== matchingAttribution) { + null + } else { + recordForExactWriterEnvelopeLocked( + event = event, + attribution = matchingAttribution, + consumedAttribution = tag, + ) + } + } + + FetchDisposition.InFlight, + is FetchDisposition.Committing, + -> { + prepared = + PreparedReaderRow( + consumedAttribution = tag, + ownerAttribution = matchingAttribution, + matchingAttribution = matchingAttribution, + dropNonmatchingOnCommit = false, + ) + null + } + + else -> { + decided = true + null + } + } + } + } + if (decided) return immediate + val provisionalRow = prepared ?: return immediate + + val ownerAttribution = provisionalRow.ownerAttribution + val matchingAttribution = provisionalRow.matchingAttribution + val owner = ownerAttribution.owner + val disposition = + when (val current = owner.disposition.value) { + FetchDisposition.InFlight, + is FetchDisposition.Committing, + -> + owner.disposition.first { candidate -> + candidate !== FetchDisposition.InFlight && + candidate !is FetchDisposition.Committing + } + + else -> current + } + val committed = disposition as? FetchDisposition.Committed + if (committed != null && committed.attribution !== ownerAttribution) { + return null + } + val restartFencedMismatch = + committed != null && + provisionalRow.dropNonmatchingOnCommit && + matchingAttribution == null + val writeDidNotCommit = + disposition === FetchDisposition.Failed || + disposition === FetchDisposition.Cancelled + if (committed == null && (!writeDidNotCommit || matchingAttribution != null)) return null + // A terminal failed owner cannot lend its consumed tag to the resumed row. With no tag, + // an equal live predecessor envelope stays unchanged while different content remains SOT. + val retainedConsumedAttribution = + provisionalRow.consumedAttribution.takeUnless { writeDidNotCommit } + + return stateLock.withLock { + if (mutableState.value.readerGen != readerGen) return@withLock null + if (isSupersededRawObservation(event)) return@withLock null + rawCommitResolution + ?.takeIf { + it.readerGen == readerGen && + event.rawObservationSequence <= it.rawCommitCutoff + } + ?.let { resolution -> + return@withLock if ( + event.rawObservationSequence == resolution.authoritativeRawSequence + ) { + recordFromConvergedRawLocked( + event = event, + resolution = resolution, + consumedAttributionOverride = + retainedConsumedAttribution, + ) ?: recordForConfirmFreshAdvancedEnvelopeLocked( + event = event, + resolution = resolution, + consumedAttributionOverride = + retainedConsumedAttribution, + ) + } else { + null + } + } + if (restartFencedMismatch) { + if (pendingFenceStillActiveLocked(event, ownerAttribution)) { + throw RestartRawReaderSession() + } + return@withLock null + } + if (matchingAttribution != null) { + recordForExactWriterEnvelopeLocked( + event = event, + attribution = matchingAttribution, + consumedAttribution = retainedConsumedAttribution, + ) ?: if (provisionalRow.dropNonmatchingOnCommit) { + recordForCurrentSameValueEnvelopeLocked( + event = event, + consumedAttribution = retainedConsumedAttribution, + ) + } else { + null + } + } else { + mapReaderRowLocked( + readerGen = readerGen, + event = event, + tag = retainedConsumedAttribution, + matchingAttribution = null, + ) + } + } + } + + /** True only while this event still belongs to the unresolved live-session fence. */ + private fun pendingFenceStillActiveLocked( + event: RawReaderEvent.Row, + attribution: AttributionTag, + ): Boolean { + val boundary = writeObservationBoundary.value + return boundary.readerGen == event.readerGen && + boundary.readerSessionActive && + boundary.pendingWriteAttribution === attribution + } + + /** True when conflate has already observed a newer row/absence in this reader generation. */ + private fun isSupersededRawObservation(event: RawReaderEvent.Row): Boolean { + val boundary = writeObservationBoundary.value + return boundary.readerGen == event.readerGen && + boundary.latestRawSequence > event.rawObservationSequence + } + + /** Reuses commit-side convergence without mutating residence or advancing its revision. */ + private fun recordFromConvergedRawLocked( + event: RawReaderEvent.Row, + resolution: RawCommitResolution, + consumedAttributionOverride: AttributionTag? = null, + ): ReaderRecord? { + if (residenceRevision != resolution.residenceRevision) return null + if (residence.value !== resolution.envelope) return null + val value = event.value + return if (value == null) { + if (resolution.envelope != null) return null + ReaderRecord.Absent( + readerGen = event.readerGen, + residenceRevision = residenceRevision, + successfulWriteSequenceAtObservation = + event.successfulWriteSequenceAtObservation, + consumedAttribution = + consumedAttributionOverride ?: resolution.consumedAttribution, + activeWriteAttributionAtObservation = + event.activeWriteAttributionAtObservation, + rawObservationSequence = event.rawObservationSequence, + ) + } else { + val envelope = resolution.envelope ?: return null + if (envelope.value != value) return null + ReaderRecord.Row( + envelope = envelope, + readerGen = event.readerGen, + residenceRevision = residenceRevision, + successfulWriteSequenceAtObservation = + event.successfulWriteSequenceAtObservation, + consumedAttribution = + consumedAttributionOverride ?: resolution.consumedAttribution, + activeWriteAttributionAtObservation = + event.activeWriteAttributionAtObservation, + rawObservationSequence = event.rawObservationSequence, + ) + } + } + + /** Reuses only a confirmFresh-advanced envelope for the exact committed raw writer token. */ + private fun recordForConfirmFreshAdvancedEnvelopeLocked( + event: RawReaderEvent.Row, + resolution: RawCommitResolution, + consumedAttributionOverride: AttributionTag?, + ): ReaderRecord.Row? { + val value = event.value ?: return null + val committedEnvelope = resolution.envelope ?: return null + val currentEnvelope = residence.value ?: return null + if (residenceRevision <= resolution.residenceRevision) return null + if (currentEnvelope === committedEnvelope) return null + + val attribution = event.activeWriteAttributionAtObservation ?: return null + val disposition = + attribution.owner.disposition.value as? FetchDisposition.Committed ?: return null + if (disposition.attribution !== attribution) return null + if (!committedEnvelope.matchesWriterAttribution(value, attribution)) return null + + if (currentEnvelope.value != value) return null + if (currentEnvelope.origin != committedEnvelope.origin) return null + if (currentEnvelope.meta == null || currentEnvelope.meta === committedEnvelope.meta) { + return null + } + if (currentEnvelope.staleEpochAtCommit < committedEnvelope.staleEpochAtCommit) return null + if (currentEnvelope.directRevalidationOwner != null) return null + + return ReaderRecord.Row( + envelope = currentEnvelope, + readerGen = event.readerGen, + residenceRevision = residenceRevision, + successfulWriteSequenceAtObservation = + event.successfulWriteSequenceAtObservation, + consumedAttribution = + consumedAttributionOverride ?: resolution.consumedAttribution, + activeWriteAttributionAtObservation = + event.activeWriteAttributionAtObservation, + rawObservationSequence = event.rawObservationSequence, + ) + } + + /** Returns the exact already-installed writer envelope, avoiding a duplicate revision bump. */ + private fun recordForExactWriterEnvelopeLocked( + event: RawReaderEvent.Row, + attribution: AttributionTag, + consumedAttribution: AttributionTag?, + ): ReaderRecord.Row? { + val value = event.value ?: return null + val envelope = residence.value ?: return null + if (!envelope.matchesWriterAttribution(value, attribution)) return null + return ReaderRecord.Row( + envelope = envelope, + readerGen = event.readerGen, + residenceRevision = residenceRevision, + successfulWriteSequenceAtObservation = + event.successfulWriteSequenceAtObservation, + consumedAttribution = consumedAttribution, + activeWriteAttributionAtObservation = event.activeWriteAttributionAtObservation, + rawObservationSequence = event.rawObservationSequence, + ) + } + + /** Reuses the live same-value envelope for a fenced exact row after residence advancement. */ + private fun recordForCurrentSameValueEnvelopeLocked( + event: RawReaderEvent.Row, + consumedAttribution: AttributionTag?, + ): ReaderRecord.Row? { + val value = event.value ?: return null + val envelope = residence.value ?: return null + if (envelope.value != value) return null + return ReaderRecord.Row( + envelope = envelope, + readerGen = event.readerGen, + residenceRevision = residenceRevision, + successfulWriteSequenceAtObservation = + event.successfulWriteSequenceAtObservation, + consumedAttribution = consumedAttribution, + activeWriteAttributionAtObservation = event.activeWriteAttributionAtObservation, + rawObservationSequence = event.rawObservationSequence, + ) + } + + private fun ValueEnvelope.matchesWriterAttribution( + value: Any, + attribution: AttributionTag, + ): Boolean = + this.value == value && + origin == attribution.origin && + meta === attribution.meta && + staleEpochAtCommit == attribution.staleEpochAtCommit && + directRevalidationOwner == null + + /** Installs one already-authorized adapter observation while [stateLock] is held. */ + private fun mapReaderRowLocked( + readerGen: Long, + event: RawReaderEvent.Row, + tag: AttributionTag?, + matchingAttribution: AttributionTag?, + ): ReaderRecord { + val value = event.value + val record = if (value == null) { + val revision = replaceResidenceLocked(null) + ReaderRecord.Absent( + readerGen = readerGen, + residenceRevision = revision, + successfulWriteSequenceAtObservation = + event.successfulWriteSequenceAtObservation, + consumedAttribution = tag, + activeWriteAttributionAtObservation = + event.activeWriteAttributionAtObservation, + rawObservationSequence = event.rawObservationSequence, + ) + } else { + val current = residence.value + val envelope = + when { + matchingAttribution != null -> + ValueEnvelope( + value = value, + origin = matchingAttribution.origin, + meta = matchingAttribution.meta, + staleEpochAtCommit = matchingAttribution.staleEpochAtCommit, + ) + + tag != null -> + ValueEnvelope( + value = value, + origin = Origin.SOT, + meta = null, + staleEpochAtCommit = mutableState.value.staleEpoch, + ) + + current != null && current.value == value -> current + + else -> + ValueEnvelope( + value = value, + origin = Origin.SOT, + meta = null, + staleEpochAtCommit = mutableState.value.staleEpoch, + ) + } + val revision = replaceResidenceLocked(envelope) + ReaderRecord.Row( + envelope = envelope, + readerGen = readerGen, + residenceRevision = revision, + successfulWriteSequenceAtObservation = + event.successfulWriteSequenceAtObservation, + consumedAttribution = tag, + activeWriteAttributionAtObservation = + event.activeWriteAttributionAtObservation, + rawObservationSequence = event.rawObservationSequence, + ) + } + return record + } + + /** Converts one adapter outage into a generation-bound record without changing residence. */ + private suspend fun readerFailureRecord( + readerGen: Long, + exception: StoreException, + ): ReaderRecord? = + stateLock.withLock { + if (mutableState.value.readerGen != readerGen) return@withLock null + ReaderRecord.Failure(exception, readerGen, residenceRevision) + } + + /** Waits out destructive mutation tails, then resolves a queued record against live state. */ + private suspend fun resolveCurrentRecord(record: ReaderRecord): ReaderResolution? { + while (true) { + val status = bookkeeper.status(key) + var barrier: CompletableDeferred? = null + val resolved = + stateLock.withLock { + barrier = destructiveMutationBarrier + if (barrier == null) { + resolveCurrentRecord( + record = record, + currentReaderGen = mutableState.value.readerGen, + currentResidence = residence.value, + currentResidenceRevision = residenceRevision, + )?.let { current -> + ReaderResolution( + record = current, + state = mutableState.value, + status = status, + nowEpochMillis = wallClock.nowEpochMillis(), + projectionBase = + when (current) { + is ReaderRecord.Row -> + projectionBaseLocked( + current.envelope, + current.residenceRevision, + ) + is ReaderRecord.Absent -> + projectionBaseLocked( + envelope = null, + revision = current.residenceRevision, + ) + is ReaderRecord.Failure -> null + }, + ) + } + } else { + null + } + } + val pending = barrier ?: return resolved + pending.await() + } + } + + /** Installs the fence that prevents reactive delivery from observing a delete mid-tail. */ + private suspend fun beginDestructiveMutation(): CompletableDeferred = + stateLock.withLock { + check(destructiveMutationBarrier == null) { + "A destructive source-of-truth mutation is already active." + } + CompletableDeferred().also { destructiveMutationBarrier = it } + } + + /** Releases a destructive fence on every terminal path without stranding waiting readers. */ + private suspend fun finishDestructiveMutation(barrier: CompletableDeferred) { + try { + stateLock.withLock { + if (destructiveMutationBarrier === barrier) { + destructiveMutationBarrier = null + } + } + } finally { + barrier.complete(Unit) + } + } + + /** Plans one read against a coherent snapshot. */ + private fun planFor( + freshness: Freshness, + snapshot: KeyState, + envelope: ValueEnvelope?, + nowEpochMillis: Long, + status: KeyStatus?, + ): FetchPlan = + validator.plan( + FreshnessContext( + hasResidentValue = envelope != null, + meta = envelope?.meta, + epochStale = envelope != null && envelope.staleEpochAtCommit < snapshot.staleEpoch, + freshness = freshness, + nowEpochMillis = nowEpochMillis, + status = status, + ), + ) + + private fun planFor( + freshness: Freshness, + snapshot: ResidenceSnapshot, + envelope: ValueEnvelope? = snapshot.envelope, + ): FetchPlan = + planFor( + freshness = freshness, + snapshot = snapshot.state, + envelope = envelope, + nowEpochMillis = snapshot.nowEpochMillis, + status = snapshot.status, + ) + + private fun staleServingTolerated(freshness: Freshness): Boolean = + freshness == Freshness.CachedOrFetch || freshness == Freshness.StaleIfError + + /** Recognizes both fetched envelopes and the exact envelope installed by a writer boundary. */ + private fun isEngineConfirmedEnvelope(envelope: ValueEnvelope): Boolean = + envelope.origin == Origin.FETCHER || rawCommitResolution?.envelope === envelope + + /** Keeps synthetic-writer SOT provenance instead of re-stamping that exact envelope MEMORY. */ + private fun canRestampEngineMemoryOrigin( + memoryEnvelope: ValueEnvelope?, + memoryRevision: Long, + currentEnvelope: ValueEnvelope?, + currentRevision: Long, + ): Boolean = + canRestampMemoryOrigin( + memoryEnvelope = memoryEnvelope, + memoryRevision = memoryRevision, + currentEnvelope = currentEnvelope, + currentRevision = currentRevision, + ) && + !( + memoryEnvelope?.origin == Origin.SOT && + rawCommitResolution?.envelope === memoryEnvelope + ) + + private fun revalidatedSatisfiesDemand( + freshness: Freshness, + snapshot: ResidenceSnapshot, + plan: FetchPlan, + ): Boolean = + when (freshness) { + Freshness.MustBeFresh -> + snapshot.envelope?.let { envelope -> + isEngineConfirmedEnvelope(envelope) && + envelope.meta != null && + envelope.staleEpochAtCommit >= snapshot.state.staleEpoch && + snapshot.status?.durablyStale != true + } == true + + Freshness.CachedOrFetch, + Freshness.StaleIfError, + Freshness.LocalOnly, + is Freshness.MaxAge, + -> plan is FetchPlan.Skip + } + + /** Reserves joined/owned work and returns the exact residence/plan used under stateLock. */ + private suspend fun reserveFetch( + freshness: Freshness, + collectorEligibleResidence: ValueEnvelope? = null, + collectorEligibleRevision: Long? = null, + enforceCollectorEligibility: Boolean = false, + ): FetchReservation? { + while (true) { + val statusResidenceRevision = stateLock.withLock { residenceRevision } + val status = bookkeeper.status(key) + var retryStaleStatus = false + var pendingRevalidationOwner: FetchTicket? = null + val planned = + stateLock.withLock { + ensureOpen() + val now = wallClock.nowEpochMillis() + val snapshot = mutableState.value + val currentResidence = residence.value + if (residenceRevision != statusResidenceRevision) { + retryStaleStatus = true + return@withLock null + } + val directOwner = currentResidence?.directRevalidationOwner + val directDisposition = + directOwner?.disposition?.value as? FetchDisposition.Revalidated + if ( + directOwner != null && + directDisposition?.envelope === currentResidence && + !directOwner.outcome.isCompleted + ) { + pendingRevalidationOwner = directOwner + return@withLock null + } + val planningResidence = + if ( + enforceCollectorEligibility && + currentResidence?.directRevalidationOwner != null && + currentResidence !== collectorEligibleResidence + ) { + collectorEligibleResidence + } else { + currentResidence + } + val planningRevision = + if (planningResidence === currentResidence) { + residenceRevision + } else { + checkNotNull(collectorEligibleRevision) { + "A collector-owned historical residence requires its exact revision." + } + } + val plan = planFor(freshness, snapshot, planningResidence, now, status) + if (plan is FetchPlan.Skip) { + return@withLock null + } + val ticket = + FetchTicket( + outcome = CompletableDeferred(engineJob), + requestRevision = residenceRevision, + residenceRevisionAtLaunch = + currentResidence?.let { residenceRevision }, + residenceEnvelopeAtLaunch = currentResidence, + staleEpochAtLaunch = snapshot.staleEpoch, + nowEpochMillisAtLaunch = now, + statusAtLaunch = status, + ) + val result = transition(snapshot, KeyEvent.EnsureFetch(ticket)) + mutableState.value = result.state + PlannedFetchEffect( + effect = result.effect, + collectorEligibleResidence = planningResidence, + collectorEligibleRevision = planningRevision, + plan = plan, + ) + } + + if (retryStaleStatus) continue + + val owner = pendingRevalidationOwner + if (owner != null) { + owner.outcome.await() + continue + } + + val reservation = planned ?: return null + val ticket = + when (val effect = reservation.effect) { + is KeyEffect.Launch -> + effect.ticket.also { ticket -> + launchFetch( + ticket, + (reservation.plan as? FetchPlan.Conditional)?.etag, + ) + } + is KeyEffect.Join -> effect.ticket + else -> error("Ensure-fetch transition produced an invalid effect: $effect") + } + return FetchReservation( + ticket = ticket, + collectorEligibleResidence = reservation.collectorEligibleResidence, + collectorEligibleRevision = reservation.collectorEligibleRevision, + plan = reservation.plan, + ) + } + } + + /** Returns only the joined/owned identity for non-collector call sites. */ + private suspend fun ensureFetch(freshness: Freshness): FetchTicket? = + reserveFetch(freshness)?.ticket + + /** Runs the owned fetch independently of any individual waiter. */ + private fun launchFetch( + ticket: FetchTicket, + etag: String?, + ) { + val fetchJob = + engineScope.launch(start = CoroutineStart.UNDISPATCHED) { + var fetchRefHeld = false + try { + val mark = if (telemetry == null) null else TimeSource.Monotonic.markNow() + telemetry?.onFetchStarted(key) + val outcome = + try { + residencyHooks.retainFetchRef() + fetchRefHeld = true + currentCoroutineContext().ensureActive() + yield() + val result = fetcher.fetch(key, etag) + currentCoroutineContext().ensureActive() + when (result) { + is FetcherResult.Success -> + commitFetch(ticket, result.value, result.etag) + + is FetcherResult.NotModified -> + commitNotModified(ticket, result.etag) + + is FetcherResult.Error -> { + if (result.cause is CancellationException) throw result.cause + FetchOutcome.Failed( + exception = fetchResultException(result.cause), + atEpochMillis = wallClock.nowEpochMillis(), + ) + } + + FetcherResult.Deleted -> commitDeleted(ticket) + } + } catch (cancellation: CancellationException) { + ticket.outcome.cancel(cancellation) + settleFetch(ticket) + throw cancellation + } catch (failure: Throwable) { + FetchOutcome.Failed( + exception = fetchException(failure), + atEpochMillis = wallClock.nowEpochMillis(), + ) + } + + finishFetch(ticket, outcome, mark) + } finally { + if (fetchRefHeld) residencyHooks.releaseFetchRef() + } + } + + fetchJob.invokeOnCompletion { failure -> + if (failure != null) ticket.outcome.cancel(storeClosedCancellation()) + } + } + + /** Persists a value, closes raw source order at normal return, then converges the writer. */ + private suspend fun commitFetch( + ticket: FetchTicket, + value: V, + etag: String?, + ): FetchOutcome = + maintenanceCoordinator.withCommit(keyId.namespace) { + writeLock.withLock { + val meta = EngineStoreMeta(wallClock.nowEpochMillis(), etag) + var attribution: AttributionTag? = null + val effect = + stateLock.withLock { + val result = + transition( + mutableState.value, + KeyEvent.CommitFetch( + ticket = ticket, + value = value, + meta = meta, + ), + ) + attribution = result.state.attribution + if (result.effect == KeyEffect.Commit) { + val committedAttribution = checkNotNull(attribution) + mutableState.value = result.state + ticket.disposition.value = + FetchDisposition.Committing( + attribution = committedAttribution, + successfulWriteSequenceAtStart = + writeObservationBoundary.value.successfulSequence, + ) + updateWriteObservationBoundary { current -> + current.copy( + readerGen = result.state.readerGen, + observedAttribution = committedAttribution, + activeAttribution = committedAttribution, + activeRawPhase = ActiveRawPhase.Unobserved, + ) + } + } else { + mutableState.value = result.state + } + result.effect + } + + when (effect) { + KeyEffect.Superseded -> return@withLock FetchOutcome.Superseded + KeyEffect.Commit -> Unit + else -> error("Commit-fetch transition produced an invalid effect: $effect") + } + + val stamped = checkNotNull(attribution) + try { + sot.write(key, value) + } catch (cancellation: CancellationException) { + withContext(NonCancellable) { + stateLock.withLock { + terminalizeFailedWriteLocked( + stamped = stamped, + ticket = ticket, + disposition = FetchDisposition.Cancelled, + ) + } + } + throw cancellation + } catch (failure: Throwable) { + val exception = writeException(failure) + val atEpochMillis = wallClock.nowEpochMillis() + withContext(NonCancellable) { + stateLock.withLock { + terminalizeFailedWriteLocked( + stamped = stamped, + ticket = ticket, + disposition = FetchDisposition.Failed, + ) + } + } + bookkeeper.recordFailure(key, atEpochMillis) + return@withLock FetchOutcome.Failed( + exception = exception, + atEpochMillis = atEpochMillis, + bookkeepingRecorded = true, + ) + } + + // This CAS is the first instruction after normal write return. It separates every + // mutation-era observation from later source authority without waiting for stateLock. + val closedWriteBoundary = closeSuccessfulWriteBoundary() + val committedWriteSequence = withContext(NonCancellable) { + val sequence = + stateLock.withLock { + val committed = + convergeSuccessfulWriteLocked( + stamped = stamped, + value = value, + closed = closedWriteBoundary, + ) + ticket.disposition.value = + FetchDisposition.Committed( + successfulWriteSequence = committed.successfulWriteSequence, + attribution = stamped, + rawReaderGen = committed.readerGen, + rawCommitCutoff = committed.rawCommitCutoff, + authoritativeRawSequence = + committed.authoritativeRawSequence, + ) + committed.successfulWriteSequence + } + bookkeeper.recordSuccess(key, meta) + sequence + } + val disposition = + ticket.disposition.value as? FetchDisposition.Committed + ?: error("A successful write did not publish Committed disposition.") + FetchOutcome.Committed( + value = value, + successfulWriteSequence = committedWriteSequence, + attribution = stamped, + rawReaderGen = disposition.rawReaderGen, + rawCommitCutoff = disposition.rawCommitCutoff, + authoritativeRawSequence = disposition.authoritativeRawSequence, + ) + } + } + + /** + * Commits an acknowledged source-of-truth value without fetching or recording success. + * + * A synthetic ticket participates only in the attribution/disposition handshake; it never + * enters the fetch slot and never launches work. + */ + internal suspend fun applyWrite(value: V) { + ensureOpen() + val meta = EngineStoreMeta(wallClock.nowEpochMillis(), etag = null) + val ticket = FetchTicket(CompletableDeferred()) + maintenanceCoordinator.withCommit(keyId.namespace) { + writeLock.withLock { + val stamped = + stateLock.withLock { + val result = + transition( + mutableState.value, + KeyEvent.ApplyWrite( + ticket = ticket, + value = value, + meta = meta, + ), + ) + check(result.effect == KeyEffect.CommitWrite) { + "Apply-write transition produced an invalid effect: ${result.effect}" + } + mutableState.value = result.state + val attribution = checkNotNull(result.state.attribution) + ticket.disposition.value = + FetchDisposition.Committing( + attribution = attribution, + successfulWriteSequenceAtStart = + writeObservationBoundary.value.successfulSequence, + ) + updateWriteObservationBoundary { current -> + current.copy( + readerGen = result.state.readerGen, + observedAttribution = attribution, + activeAttribution = attribution, + activeRawPhase = ActiveRawPhase.Unobserved, + ) + } + attribution + } + + try { + sot.write(key, value) + } catch (cancellation: CancellationException) { + withContext(NonCancellable) { + stateLock.withLock { + terminalizeFailedWriteLocked( + stamped = stamped, + ticket = ticket, + disposition = FetchDisposition.Cancelled, + ) + } + } + throw cancellation + } catch (failure: Throwable) { + withContext(NonCancellable) { + stateLock.withLock { + terminalizeFailedWriteLocked( + stamped = stamped, + ticket = ticket, + disposition = FetchDisposition.Failed, + ) + } + } + throw writeHandleException(failure) + } + + // This CAS must remain the first non-suspending instruction after normal return. + val closedWriteBoundary = closeSuccessfulWriteBoundary() + withContext(NonCancellable) { + stateLock.withLock { + val committed = + convergeSuccessfulWriteLocked( + stamped = stamped, + value = value, + closed = closedWriteBoundary, + ) + ticket.disposition.value = + FetchDisposition.Committed( + successfulWriteSequence = committed.successfulWriteSequence, + attribution = stamped, + rawReaderGen = committed.readerGen, + rawCommitCutoff = committed.rawCommitCutoff, + authoritativeRawSequence = + committed.authoritativeRawSequence, + ) + } + } + } + } + events.tryEmit(KeyEvents.Written(key, Origin.SOT)) + } + + /** Refreshes resident metadata and durable success without a fetch. */ + internal suspend fun confirmFresh(etag: String?) { + ensureOpen() + val meta = EngineStoreMeta(wallClock.nowEpochMillis(), etag) + maintenanceCoordinator.withCommit(keyId.namespace) { + writeLock.withLock { + var replaced = false + stateLock.withLock state@{ + val current = residence.value ?: return@state + replaceResidenceLocked( + ValueEnvelope( + value = current.value, + origin = current.origin, + meta = meta, + staleEpochAtCommit = mutableState.value.staleEpoch, + ), + preserveProjectionAuthorizationLineage = true, + ) + syncObservedAttributionLocked(mutableState.value) + replaced = true + } + if (replaced) { + withContext(NonCancellable) { bookkeeper.recordSuccess(key, meta) } + } + } + } + } + + /** Closes raw source order and converges its durable winner before bookkeeping. */ + private fun convergeSuccessfulWriteLocked( + stamped: AttributionTag, + value: V, + closed: ClosedWriteBoundary, + ): DurableWriteResolution { + val matchingObservation = closed.phase.matchingObservationOrNull() + val authoritativeRawSequence = + matchingObservation?.rawSequence + + // RYW makes the successful writer the winner over every pre-close intermediate. Only the + // exact captured matching token may later reuse this installed FETCHER envelope. + installWriterEnvelopeLocked(value, stamped) + val consumed = + transition( + mutableState.value, + KeyEvent.ConsumeAttribution(matchingObservation?.attributionAtObservation), + ) + mutableState.value = consumed.state + val consumedAttribution = (consumed.effect as KeyEffect.Consumed).tag + val revoked = transition(consumed.state, KeyEvent.RevokeAttribution) + mutableState.value = revoked.state + + syncObservedAttributionLocked(mutableState.value) + updateWriteObservationBoundary { current -> + if (current.activeAttribution === stamped) { + current.copy( + activeAttribution = null, + activeRawPhase = ActiveRawPhase.Unobserved, + ) + } else { + current + } + } + rawCommitResolution = + RawCommitResolution( + readerGen = closed.readerGen, + rawCommitCutoff = closed.rawCommitCutoff, + authoritativeRawSequence = authoritativeRawSequence, + residenceRevision = residenceRevision, + envelope = residence.value, + consumedAttribution = consumedAttribution, + ) + return DurableWriteResolution( + successfulWriteSequence = closed.successfulWriteSequence, + readerGen = closed.readerGen, + rawCommitCutoff = closed.rawCommitCutoff, + authoritativeRawSequence = authoritativeRawSequence, + ) + } + + private fun installWriterEnvelopeLocked( + value: V, + stamped: AttributionTag, + ) { + replaceResidenceLocked( + ValueEnvelope( + value = value, + origin = stamped.origin, + meta = stamped.meta, + staleEpochAtCommit = stamped.staleEpochAtCommit, + ), + ) + } + + /** Atomically closes the raw phase and fences queued pre-return notifications. */ + private fun closeSuccessfulWriteBoundary(): ClosedWriteBoundary { + while (true) { + val current = writeObservationBoundary.value + val nextSequence = current.successfulSequence + 1L + val pendingWriteAttribution = + if ( + current.readerSessionActive && + current.activeRawPhase !is ActiveRawPhase.Matching + ) { + current.activeAttribution ?: current.pendingWriteAttribution + } else { + current.pendingWriteAttribution + } + val updated = + current.copy( + successfulSequence = nextSequence, + activeRawPhase = ActiveRawPhase.Unobserved, + pendingWriteAttribution = pendingWriteAttribution, + ) + if (writeObservationBoundary.compareAndSet(current, updated)) { + return ClosedWriteBoundary( + readerGen = current.readerGen, + rawCommitCutoff = current.latestRawSequence, + phase = current.activeRawPhase, + successfulWriteSequence = nextSequence, + ) + } + } + } + + /** Atomically revokes failed-write provenance and wakes every captured provisional row. */ + private fun terminalizeFailedWriteLocked( + stamped: AttributionTag, + ticket: FetchTicket, + disposition: FetchDisposition, + ) { + require( + disposition === FetchDisposition.Failed || + disposition === FetchDisposition.Cancelled, + ) + val revoked = transition(mutableState.value, KeyEvent.RevokeAttribution) + mutableState.value = revoked.state + syncObservedAttributionLocked(revoked.state) + updateWriteObservationBoundary { current -> + if (current.activeAttribution === stamped) { + current.copy( + activeAttribution = null, + activeRawPhase = ActiveRawPhase.Unobserved, + ) + } else { + current + } + } + ticket.disposition.value = disposition + } + + /** Applies NotModified only when its launch baseline is still the live residence revision. */ + private suspend fun commitNotModified( + ticket: FetchTicket, + etag: String?, + ): FetchOutcome = + maintenanceCoordinator.withCommit(keyId.namespace) { + writeLock.withLock { + val now = wallClock.nowEpochMillis() + val baseline = ticket.residenceRevisionAtLaunch + var refreshedMeta: StoreMeta? = null + val outcome = + stateLock.withLock { + val result = + transition(mutableState.value, KeyEvent.CommitRevalidated(ticket)) + val classified = + when (result.effect) { + KeyEffect.CommitRevalidation -> { + val current = residence.value + if (baseline == null && current == null) { + FetchOutcome.Failed( + exception = notModifiedWithoutValueException(), + atEpochMillis = now, + ) + } else if ( + baseline == null || + current == null || + residenceRevision != baseline + ) { + // A null launch baseline with residence present at commit + // is an obsolete launch snapshot (residence hydrated + // mid-flight), not an adapter-contract violation; only a + // 304 with no value on either side is Failed. + FetchOutcome.ObsoleteRevalidation + } else { + val age = elapsedAge(now, current.meta) + val meta = EngineStoreMeta(now, etag ?: current.meta?.etag) + refreshedMeta = meta + val refreshed = + current.copy( + origin = Origin.FETCHER, + meta = meta, + staleEpochAtCommit = result.state.staleEpoch, + directRevalidationOwner = ticket, + ) + val revision = replaceResidenceLocked(refreshed) + FetchOutcome.Revalidated(revision, refreshed, age) + } + } + + KeyEffect.Superseded -> FetchOutcome.Superseded + else -> error( + "Commit-revalidated transition produced an invalid effect: " + + result.effect, + ) + } + markDisposition(ticket, classified) + mutableState.value = result.state + syncObservedAttributionLocked(result.state) + classified + } + refreshedMeta?.let { meta -> + withContext(NonCancellable) { bookkeeper.recordSuccess(key, meta) } + } + outcome + } + } + + /** Applies a server deletion only after its ticket is still proven current. */ + private suspend fun commitDeleted(ticket: FetchTicket): FetchOutcome { + val outcome = commitDeletedUnderFence(ticket) + if (outcome is FetchOutcome.Deleted) { + telemetry?.onCleared(key) + events.tryEmit(KeyEvents.Deleted(key)) + } + return outcome + } + + /** Runs the destructive server-deletion transaction without invoking extension code. */ + private suspend fun commitDeletedUnderFence(ticket: FetchTicket): FetchOutcome = + maintenanceCoordinator.withCommit(keyId.namespace) { + writeLock.withLock { + val superseded = + stateLock.withLock { + val slot = mutableState.value.fetch as? FetchSlot.InFlight + slot == null || + slot.ticket !== ticket || + slot.clearEpochAtLaunch != mutableState.value.clearEpoch + } + if (superseded) return@withLock FetchOutcome.Superseded + + withContext(NonCancellable) { + val barrier = beginDestructiveMutation() + try { + val deleteFailure = + try { + sot.delete(key) + null + } catch (cancellation: CancellationException) { + throw cancellation + } catch (failure: Throwable) { + FetchOutcome.Failed( + exception = serverDeletePersistenceException(failure), + atEpochMillis = wallClock.nowEpochMillis(), + ) + } + if (deleteFailure != null) return@withContext deleteFailure + + val absenceRevision = stateLock.withLock { + val result = + transition(mutableState.value, KeyEvent.CommitDeleted(ticket)) + check(result.effect == KeyEffect.CommitDelete) { + "Commit-deleted transition produced an invalid effect: ${result.effect}" + } + ticket.disposition.value = FetchDisposition.Deleted + mutableState.value = result.state + syncObservedAttributionLocked(result.state) + replaceResidenceLocked(null) + } + try { + bookkeeper.forget(key) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (failure: Throwable) { + return@withContext FetchOutcome.Failed( + exception = + maintenancePersistenceException( + operation = "server deletion cleanup", + failure = failure, + ), + atEpochMillis = wallClock.nowEpochMillis(), + ) + } + FetchOutcome.Deleted(absenceRevision) + } finally { + finishDestructiveMutation(barrier) + } + } + } + } + + private suspend fun settleFetch(ticket: FetchTicket) { + withContext(NonCancellable) { + stateLock.withLock { + val result = transition(mutableState.value, KeyEvent.SettleFetch(ticket)) + mutableState.value = result.state + } + } + } + + private suspend fun finishFetch( + ticket: FetchTicket, + outcome: FetchOutcome, + mark: TimeMark?, + ) { + if (outcome is FetchOutcome.Failed && !outcome.bookkeepingRecorded) { + // Failure bookkeeping remains cancellable so engine cancellation releases the ordered + // write/fence admission. Only the post-release publication tail is non-cancellable. + val classified = finishFailedFetch(ticket, outcome) + withContext(NonCancellable) { + notifyFetchTerminal(classified, mark) + notifyFetchEvent(classified) + completeTicket(ticket, classified) + } + } else { + withContext(NonCancellable) { + val classified = + if (outcome is FetchOutcome.Failed) { + outcome + } else { + stateLock.withLock { classifySettledOutcome(ticket, outcome) } + } + notifyFetchTerminal(classified, mark) + notifyFetchEvent(classified) + completeTicket(ticket, classified) + } + } + } + + /** Runs the failed-fetch persistence tail and returns only after every engine lock is released. */ + private suspend fun finishFailedFetch( + ticket: FetchTicket, + outcome: FetchOutcome.Failed, + ): FetchOutcome = + try { + maintenanceCoordinator.withCommit(keyId.namespace) { + writeLock.withLock { + val classified = + stateLock.withLock { + classifySettledOutcome(ticket, outcome) + } + if (classified is FetchOutcome.Failed) { + bookkeeper.recordFailure(key, classified.atEpochMillis) + } + classified + } + } + } catch (cancellation: CancellationException) { + ticket.outcome.cancel(cancellation) + settleFetch(ticket) + throw cancellation + } + + /** Fires terminal telemetry after settlement and before any waiter observes ticket completion. */ + private fun notifyFetchTerminal( + outcome: FetchOutcome, + mark: TimeMark?, + ) { + val sink = telemetry ?: return + when (outcome) { + is FetchOutcome.Committed, + is FetchOutcome.Revalidated, + -> sink.onFetchSucceeded(key, checkNotNull(mark).elapsedNow()) + + is FetchOutcome.Failed -> + sink.onFetchFailed(key, outcome.exception.error, checkNotNull(mark).elapsedNow()) + + is FetchOutcome.Deleted, + FetchOutcome.ObsoleteRevalidation, + FetchOutcome.Superseded, + -> Unit + } + } + + /** Publishes successful fetch writes after classification and before ticket completion. */ + private fun notifyFetchEvent(outcome: FetchOutcome) { + if (outcome is FetchOutcome.Committed) { + events.tryEmit(KeyEvents.Written(key, Origin.FETCHER)) + } + } + + private fun classifySettledOutcome( + ticket: FetchTicket, + outcome: FetchOutcome, + ): FetchOutcome { + val result = transition(mutableState.value, KeyEvent.SettleFetch(ticket)) + val classified = when (result.effect) { + KeyEffect.Superseded -> FetchOutcome.Superseded + KeyEffect.Settled, + KeyEffect.Ignored, + -> outcome + + else -> error("Settle-fetch transition produced an invalid effect: ${result.effect}") + } + markDisposition(ticket, classified) + mutableState.value = result.state + return classified + } + + private fun completeTicket( + ticket: FetchTicket, + outcome: FetchOutcome, + ) { + if (engineJob.isActive) { + ticket.outcome.complete(outcome) + } else { + ticket.outcome.cancel(storeClosedCancellation()) + } + } + + private fun markDisposition( + ticket: FetchTicket, + outcome: FetchOutcome, + ) { + ticket.disposition.value = + when (outcome) { + is FetchOutcome.Committed -> + FetchDisposition.Committed( + successfulWriteSequence = outcome.successfulWriteSequence, + attribution = outcome.attribution, + rawReaderGen = outcome.rawReaderGen, + rawCommitCutoff = outcome.rawCommitCutoff, + authoritativeRawSequence = outcome.authoritativeRawSequence, + ) + is FetchOutcome.Revalidated -> FetchDisposition.Revalidated(outcome.envelope) + is FetchOutcome.Deleted -> FetchDisposition.Deleted + is FetchOutcome.Failed -> FetchDisposition.Failed + FetchOutcome.ObsoleteRevalidation -> FetchDisposition.ObsoleteRevalidation + FetchOutcome.Superseded -> FetchDisposition.Superseded + } + } + + internal suspend fun invalidate() { + maintenanceCoordinator.withCommit(keyId.namespace) { + writeLock.withLock { + try { + bookkeeper.markStale(key) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (failure: Throwable) { + throw maintenancePersistenceException("invalidate", failure) + } + applyEvent(KeyEvent.Invalidate) + } + } + telemetry?.onInvalidated(key) + events.tryEmit(KeyEvents.Invalidated(key)) + } + + /** Signals resident demand only while the previously advanced watermark still covers it. */ + internal suspend fun invalidateResident() { + maintenanceCoordinator.withCommit(keyId.namespace) { + writeLock.withLock { + if (bookkeeper.status(key)?.durablyStale == true) { + applyEvent(KeyEvent.Invalidate) + } + } + } + telemetry?.onInvalidated(key) + events.tryEmit(KeyEvents.Invalidated(key)) + } + + /** Deletes persistence first, then performs the irreversible state/bookkeeping tail. */ + internal suspend fun clear() { + maintenanceCoordinator.withCommit(keyId.namespace) { + writeLock.withLock { + withContext(NonCancellable) { + val barrier = beginDestructiveMutation() + try { + try { + sot.delete(key) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (failure: Throwable) { + throw clearPersistenceException(failure) + } + + stateLock.withLock { applyClearTransitionLocked() } + try { + bookkeeper.forget(key) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (failure: Throwable) { + throw maintenancePersistenceException("clear", failure) + } + } finally { + finishDestructiveMutation(barrier) + } + } + } + } + telemetry?.onCleared(key) + events.tryEmit(KeyEvents.Deleted(key)) + } + + /** Applies only the resident clear atom; store-scoped maintenance owns the durable fence. */ + internal suspend fun clearResident() { + writeLock.withLock { + withContext(NonCancellable) { + val barrier = beginDestructiveMutation() + try { + stateLock.withLock { applyClearTransitionLocked() } + } finally { + finishDestructiveMutation(barrier) + } + } + } + } + + /** Reports one completed store-scoped clear after its maintenance fence has been released. */ + internal fun notifyBulkClearCompleted() { + telemetry?.onCleared(key) + events.tryEmit(KeyEvents.Deleted(key)) + } + + /** Applies the clear transition while [stateLock] is held. */ + private fun applyClearTransitionLocked() { + val result = transition(mutableState.value, KeyEvent.Clear) + mutableState.value = result.state + syncObservedAttributionLocked(result.state) + check(result.effect == KeyEffect.ClearResidence) { + "Clear transition produced an invalid effect: ${result.effect}" + } + replaceResidenceLocked(null) + } + + /** Direct one-shot hydration used by get and memory-miss stream startup. */ + private suspend fun hydrateFromSot(): ResidenceSnapshot = + writeLock.withLock { + val status = bookkeeper.status(key) + val capturedRevision = stateLock.withLock { residenceRevision } + val row = + try { + sot.reader(key).first() + } catch (cancellation: CancellationException) { + throw cancellation + } catch (failure: Throwable) { + throw readerException(failure) + } + + stateLock.withLock { + val current = residence.value + val resolved = + when { + current != null -> current + residenceRevision != capturedRevision -> current + row == null -> residence.value + + else -> { + val currentEpoch = mutableState.value.staleEpoch + val staleEpochAtCommit = + if (status?.durablyStale == true) currentEpoch - 1L else currentEpoch + val hydratedMeta = + status?.meta?.let { meta -> + EngineStoreMeta( + writtenAtEpochMillis = meta.writtenAtEpochMillis, + etag = null, + ) + } + ValueEnvelope( + value = row, + origin = Origin.SOT, + meta = hydratedMeta, + staleEpochAtCommit = staleEpochAtCommit, + ).also(::replaceResidenceLocked) + } + } + ResidenceSnapshot( + state = mutableState.value, + envelope = resolved, + revision = residenceRevision, + status = status, + nowEpochMillis = wallClock.nowEpochMillis(), + projectionBase = projectionBaseLocked(resolved, residenceRevision), + ) + } + } + + /** Coherent state/residence snapshot used at public delivery boundaries. */ + private suspend fun residenceSnapshot(): ResidenceSnapshot { + val status = bookkeeper.status(key) + return stateLock.withLock { + ResidenceSnapshot( + state = mutableState.value, + envelope = residence.value, + revision = residenceRevision, + status = status, + nowEpochMillis = wallClock.nowEpochMillis(), + projectionBase = projectionBaseLocked(residence.value, residenceRevision), + ) + } + } + + /** Distinguishes a newer semantic residence from a same-envelope reader replay. */ + @Suppress("UNCHECKED_CAST") + private fun residenceAdvancedFrom( + ticket: FetchTicket, + snapshot: ResidenceSnapshot, + ): Boolean { + val launchEnvelope = ticket.residenceEnvelopeAtLaunch as? ValueEnvelope + return snapshot.state.staleEpoch > ticket.staleEpochAtLaunch || + ( + snapshot.revision != ticket.requestRevision && + snapshot.envelope != launchEnvelope + ) + } + + /** Builds one live stream with a collector-local serialized delivery controller. */ + internal fun stream(freshness: Freshness): Flow> { + ensureOpen() + return channelFlow { + ensureOpen() + val producer = this + val closeHandle = + closeSignal.invokeOnCompletion { producer.cancel(storeClosedCancellation()) } + try { + val memory = residenceSnapshot() + var startupReaderFailure: StoreException? = null + var hydrated: ResidenceSnapshot? = null + if (memory.envelope == null) { + try { + hydrated = hydrateFromSot() + } catch (failure: StoreException) { + startupReaderFailure = failure + } + } + + var planning = hydrated ?: residenceSnapshot() + var planningEpoch = planning.state.staleEpoch + var planningEligibleEnvelope = + if ( + planning.envelope?.directRevalidationOwner != null && + planning.envelope !== memory.envelope + ) { + memory.envelope + } else { + planning.envelope + } + var planningEligibleRevision = + if (planningEligibleEnvelope === memory.envelope) { + memory.revision + } else { + planning.revision + } + var plan = + planFor( + freshness = freshness, + snapshot = planning, + envelope = planningEligibleEnvelope, + ) + val initialReservation = + if (plan is FetchPlan.Skip) { + null + } else { + reserveFetch( + freshness = freshness, + collectorEligibleResidence = planningEligibleEnvelope, + collectorEligibleRevision = planningEligibleRevision, + enforceCollectorEligibility = + planning.envelope?.directRevalidationOwner != null && + planning.envelope !== planningEligibleEnvelope, + ) + } + val reservedCollectorEnvelope = + initialReservation?.collectorEligibleResidence ?: planningEligibleEnvelope + val reservedCollectorRevision = + initialReservation?.collectorEligibleRevision ?: planningEligibleRevision + val reservedPlan = initialReservation?.plan ?: plan + var initialTicket = initialReservation?.ticket + planning = residenceSnapshot() + planningEligibleEnvelope = + if ( + planning.envelope?.directRevalidationOwner != null && + planning.envelope !== memory.envelope + ) { + memory.envelope + } else { + planning.envelope + } + planningEligibleRevision = + if (planningEligibleEnvelope === memory.envelope) { + memory.revision + } else { + planning.revision + } + plan = + planFor( + freshness = freshness, + snapshot = planning, + envelope = planningEligibleEnvelope, + ) + + val delivery = + StreamDelivery( + producer = producer, + freshness = freshness, + startupReaderFailure = startupReaderFailure, + ) + beforeInitialDeliveryTestGate() + val initialDelivery = delivery.deliverInitial( + memoryEnvelope = memory.envelope, + memoryRevision = memory.revision, + reservedCollectorEnvelope = reservedCollectorEnvelope, + reservedCollectorRevision = reservedCollectorRevision, + reservedPlan = reservedPlan, + ticket = initialTicket, + ) + planning = initialDelivery.snapshot + plan = initialDelivery.plan + initialTicket = initialDelivery.ticket + + if (freshness == Freshness.MustBeFresh && initialTicket != null) { + delivery.startProjectionObserver() + while (true) { + val ticket = initialTicket ?: break + val outcome = ticket.outcome.await() + beforeTicketOutcomeDeliveryTestGate() + when (outcome) { + is FetchOutcome.Committed -> { + delivery.retainCommittedTicket(ticket, outcome) + break + } + + is FetchOutcome.Revalidated -> { + delivery.clearInitialTicket(ticket) + when (val delivered = delivery.deliverRevalidated(outcome)) { + RevalidatedDelivery.Delivered -> break + RevalidatedDelivery.Obsolete -> { + initialTicket = ensureFetch(freshness) + if (initialTicket == null) break + } + + is RevalidatedDelivery.Replacement -> + initialTicket = delivered.ticket + } + } + + FetchOutcome.ObsoleteRevalidation -> { + delivery.clearInitialTicket(ticket) + initialTicket = ensureFetch(freshness) + if (initialTicket == null) break + } + + is FetchOutcome.Failed -> { + delivery.clearInitialTicket(ticket) + delivery.deliverTerminalOutcome(outcome) + close() + return@channelFlow + } + + is FetchOutcome.Deleted -> { + delivery.clearInitialTicket(ticket) + delivery.deliverTerminalOutcome(outcome) + close() + return@channelFlow + } + + FetchOutcome.Superseded -> { + delivery.clearInitialTicket(ticket) + delivery.deliverTerminalError(supersededException()) + close() + return@channelFlow + } + } + } + planning = residenceSnapshot() + planningEpoch = + maxOf( + planningEpoch, + planning.envelope?.staleEpochAtCommit ?: planningEpoch, + ) + plan = + planFor( + freshness = freshness, + snapshot = planning, + ) + initialTicket = null + } + + delivery.start( + planningEpoch = planningEpoch, + initialTicket = initialTicket, + initialPlan = plan, + ) + awaitCancellation() + } finally { + closeHandle.dispose() + } + }.conflateLatestData() + } + + /** Collector-local sequencer. Every public send occurs while [mutex] is held. */ + private inner class StreamDelivery( + private val producer: ProducerScope>, + private val freshness: Freshness, + private val startupReaderFailure: StoreException?, + ) { + private val mutex = Mutex() + private var publicHasValue = false + private var loadingVisible = false + private var localOnlyMissingEmitted = false + private var watchedTicket: FetchTicket? = null + private var awaitingCommitted: CommittedReaderWait? = null + private var handledCommittedTicket: FetchTicket? = null + private var latestReaderRecord: ReaderRecord? = null + private var publicServedStale = false + private var servedStaleForWatchedTicket = false + private var lastRevalidationRequestedRevision: Long? = null + private var terminalFailedDemand: FetchTicket? = null + private var suppressMissingUntilReaderRecovery = startupReaderFailure != null + private var serverDeletionObserved = false + private var lastDataFingerprint: DataFingerprint? = null + private var lastConfirmedRevision: Long? = null + private var projectionAuthorization: ProjectionAuthorization? = null + private var projectionObserverStarted = false + private val pendingFailureHandoffs = ArrayDeque() + private var ticketLaunchBaseline: TicketLaunchBaselineEntry? = null + + /** Propagates cooperative fetch cancellation to the owning public stream. */ + private suspend fun awaitTicketOutcome(ticket: FetchTicket): FetchOutcome = + try { + ticket.outcome.await() + } catch (cancellation: CancellationException) { + producer.cancel(cancellation) + throw cancellation + } + + /** Plans only from residence this collector is authorized to observe. */ + private fun collectorPlanFor( + snapshot: ResidenceSnapshot, + eligibleBaseline: ValueEnvelope? = lastDataFingerprint?.envelope, + eligibleBaselineRevision: Long? = + projectionAuthorization?.base?.revision ?: lastConfirmedRevision, + ): CollectorFetchPlan { + val current = snapshot.envelope + val currentIsForeignOwner = + current?.directRevalidationOwner != null && current !== eligibleBaseline + val eligibleEnvelope = if (currentIsForeignOwner) eligibleBaseline else current + val eligibleRevision = + if (currentIsForeignOwner) { + eligibleBaselineRevision ?: snapshot.revision + } else { + snapshot.revision + } + val eligibleProjectionBase = + if (currentIsForeignOwner) { + projectionAuthorization?.base?.takeIf { base -> + base.envelope === eligibleEnvelope && base.revision == eligibleRevision + } + } else { + snapshot.projectionBase + } + return CollectorFetchPlan( + eligibleEnvelope = eligibleEnvelope, + eligibleRevision = eligibleRevision, + plan = + planFor( + freshness = freshness, + snapshot = snapshot, + envelope = eligibleEnvelope, + ), + currentIsForeignOwner = currentIsForeignOwner, + eligibleProjectionBase = eligibleProjectionBase, + ) + } + + private fun collectorPlanFor( + snapshot: ResidenceSnapshot, + eligibleBaseline: EligibleBaseline, + ): CollectorFetchPlan = + collectorPlanFor( + snapshot = snapshot, + eligibleBaseline = eligibleBaseline.envelope, + eligibleBaselineRevision = eligibleBaseline.revision, + ) + + /** Rechecks collector demand under stateLock without changing the ticket's live baseline. */ + private suspend fun ensureFetchForCollector( + collectorPlan: CollectorFetchPlan, + ): FetchTicket? { + val reservation = reserveFetch( + freshness = freshness, + collectorEligibleResidence = collectorPlan.eligibleEnvelope, + collectorEligibleRevision = collectorPlan.eligibleRevision, + enforceCollectorEligibility = collectorPlan.currentIsForeignOwner, + ) ?: return null + rememberTicketLaunchBaseline( + reservation.ticket, + TicketLaunchBaseline( + reservation.collectorEligibleResidence, + reservation.collectorEligibleRevision, + reservation.plan, + ), + ) + return reservation.ticket + } + + private fun rememberTicketLaunchBaseline( + ticket: FetchTicket, + baseline: TicketLaunchBaseline, + ) { + ticketLaunchBaseline = TicketLaunchBaselineEntry(ticket, baseline) + } + + /** Keeps a mapped SoT value eligible when a later 304 owns only its refreshed metadata. */ + private fun readerEligibleBaseline( + notification: ReaderRecord, + current: ValueEnvelope?, + ): EligibleBaseline { + val visible = lastDataFingerprint?.envelope + val row = notification as? ReaderRecord.Row + if (current != null && current === visible) { + return EligibleBaseline(current, row?.residenceRevision ?: checkNotNull(lastConfirmedRevision)) + } + val mapped = row?.envelope + val sameValue = + mapped != null && + if (overlay == null) { + mapped.value == current?.value + } else { + mapped.value === current?.value + } + return if ( + current?.directRevalidationOwner != null && + mapped?.directRevalidationOwner == null && + sameValue + ) { + EligibleBaseline(mapped, row.residenceRevision) + } else { + EligibleBaseline( + envelope = visible, + revision = projectionAuthorization?.base?.revision ?: lastConfirmedRevision ?: 0L, + ) + } + } + + /** Reconstructs the policy posture that was eligible when [ticket] reserved demand. */ + private fun launchBaselineFor( + ticket: FetchTicket, + snapshot: ResidenceSnapshot, + ): TicketLaunchBaseline { + val remembered = ticketLaunchBaseline?.takeIf { it.ticket === ticket }?.baseline + @Suppress("UNCHECKED_CAST") + val launchBaseline = + remembered ?: run { + val envelope = ticket.residenceEnvelopeAtLaunch as? ValueEnvelope + TicketLaunchBaseline( + envelope = envelope, + revision = ticket.requestRevision, + plan = + validator.plan( + FreshnessContext( + hasResidentValue = envelope != null, + meta = envelope?.meta, + epochStale = + envelope != null && + envelope.staleEpochAtCommit < + ticket.staleEpochAtLaunch, + freshness = freshness, + nowEpochMillis = ticket.nowEpochMillisAtLaunch, + status = ticket.statusAtLaunch, + ), + ), + ) + } + val currentPlan = + planFor( + freshness = freshness, + snapshot = snapshot, + envelope = launchBaseline.envelope, + ) + return TicketLaunchBaseline( + envelope = launchBaseline.envelope, + revision = launchBaseline.revision, + plan = + if (launchBaseline.plan.servesResident) { + currentPlan + } else { + launchBaseline.plan + }, + ) + } + + suspend fun deliverInitial( + memoryEnvelope: ValueEnvelope?, + memoryRevision: Long, + reservedCollectorEnvelope: ValueEnvelope?, + reservedCollectorRevision: Long, + reservedPlan: FetchPlan, + ticket: FetchTicket?, + ): InitialDelivery = + mutex.withLock { + if (ticket != null) { + rememberTicketLaunchBaseline( + ticket, + TicketLaunchBaseline( + reservedCollectorEnvelope, + reservedCollectorRevision, + reservedPlan, + ), + ) + } + if ( + overlay != null && + reservedCollectorEnvelope == null && + reservedPlan !is FetchPlan.Skip && + startupReaderFailure == null + ) { + emitLoadingLocked() + } + var snapshot = residenceSnapshot() + var collectorPlan = collectorPlanFor(snapshot, memoryEnvelope, memoryRevision) + var plan = collectorPlan.plan + var effectiveTicket = ticket + if (plan !is FetchPlan.Skip && effectiveTicket == null) { + effectiveTicket = ensureFetchForCollector(collectorPlan) + snapshot = residenceSnapshot() + collectorPlan = collectorPlanFor(snapshot, memoryEnvelope, memoryRevision) + plan = collectorPlan.plan + } + afterInitialPlanningSnapshotTestGate() + val pendingSettledTailAtRecapture = + effectiveTicket != null && + !effectiveTicket.outcome.isCompleted && + (snapshot.state.fetch as? FetchSlot.InFlight)?.ticket !== effectiveTicket && + effectiveTicket.requestRevision != snapshot.revision + val pendingExactRevalidation = + pendingSettledTailAtRecapture && + (effectiveTicket.disposition.value as? FetchDisposition.Revalidated) + ?.envelope === snapshot.envelope + if (pendingExactRevalidation) { + if ( + reservedCollectorEnvelope != null && + reservedPlan.servesResident && + plan.servesResident + ) { + deliverDataLocked( + reservedCollectorEnvelope, + revision = reservedCollectorRevision, + authority = DataDeliveryAuthority.CollectorBaseline, + ) + } else { + emitLoadingLocked() + } + } else if ( + pendingSettledTailAtRecapture && + (snapshot.envelope == null || !plan.servesResident) + ) { + emitLoadingLocked() + } + val observedOuterOutcome = + when { + effectiveTicket == null -> null + effectiveTicket.outcome.isCompleted -> awaitTicketOutcome(effectiveTicket) + pendingSettledTailAtRecapture -> awaitTicketOutcome(effectiveTicket) + else -> null + } + if (pendingSettledTailAtRecapture) { + snapshot = residenceSnapshot() + collectorPlan = collectorPlanFor(snapshot, memoryEnvelope, memoryRevision) + plan = collectorPlan.plan + } + val completedOuterOutcome = + observedOuterOutcome?.takeIf { outcome -> + freshness != Freshness.MustBeFresh && + effectiveTicket?.let { ticket -> + if (outcome is FetchOutcome.Failed) { + residenceAdvancedFrom(ticket, snapshot) + } else { + ticket.requestRevision != snapshot.revision + } + } == true + } + val completedExactRevalidation = + (completedOuterOutcome as? FetchOutcome.Revalidated) + ?.takeIf { snapshot.envelope === it.envelope } + var completedExactRevalidationDelivery: RevalidatedDelivery? = null + if ( + completedExactRevalidation != null && + !revalidatedSatisfiesDemand( + snapshot, + planFor( + freshness = freshness, + snapshot = snapshot, + ), + ) + ) { + val revalidationDelivery = + deliverRevalidatedLocked( + outcome = completedExactRevalidation, + watchReplacement = false, + ) + completedExactRevalidationDelivery = revalidationDelivery + effectiveTicket = + when (val delivery = revalidationDelivery) { + is RevalidatedDelivery.Replacement -> delivery.ticket + RevalidatedDelivery.Delivered -> null + RevalidatedDelivery.Obsolete -> { + snapshot = residenceSnapshot() + collectorPlan = + collectorPlanFor(snapshot, memoryEnvelope, memoryRevision) + plan = collectorPlan.plan + if (plan is FetchPlan.Skip) { + null + } else { + ensureFetchForCollector(collectorPlan) + } + } + } + snapshot = residenceSnapshot() + collectorPlan = collectorPlanFor(snapshot, memoryEnvelope, memoryRevision) + plan = collectorPlan.plan + } + if (completedOuterOutcome is FetchOutcome.Committed) { + installCommittedWaitLocked( + checkNotNull(effectiveTicket), + completedOuterOutcome, + ) + } else if ( + completedOuterOutcome != null && + completedExactRevalidation == null && + (completedOuterOutcome !is FetchOutcome.Deleted || snapshot.envelope != null) + ) { + // The outer ticket no longer covers the final residence. Hand ownership to + // its replacement before the first public send, then surface the old outcome + // with its pre-handoff served-stale state. + val outerTicket = checkNotNull(effectiveTicket) + if (completedOuterOutcome is FetchOutcome.Deleted) { + handleOutcomeLocked(outerTicket, completedOuterOutcome, false) + serverDeletionObserved = false + effectiveTicket = null + } + val replacement = + if (plan is FetchPlan.Skip) null else ensureFetchForCollector(collectorPlan) + if (replacement != null) { + if (completedOuterOutcome is FetchOutcome.Failed) { + enqueueFailureHandoff( + SettledTicketHandoff( + ticket = outerTicket, + outcome = completedOuterOutcome, + servedStale = false, + ), + ) + } + effectiveTicket = replacement + } + snapshot = residenceSnapshot() + collectorPlan = collectorPlanFor(snapshot, memoryEnvelope, memoryRevision) + plan = collectorPlan.plan + } + var initialPublicDeliveryCompleted = + completedExactRevalidationDelivery == RevalidatedDelivery.Delivered || + (completedExactRevalidationDelivery is RevalidatedDelivery.Replacement && + completedExactRevalidationDelivery.publicDeliveryCompleted) + if ( + effectiveTicket != null && + (effectiveTicket !== ticket || observedOuterOutcome == null) + ) { + beforeReplacementDispositionClassificationTestGate() + } + val classifiedReplacementTickets = mutableSetOf() + var replacementClassifications = 0 + var replacementClassificationCapped = false + var replacementCommitRetainedServableRow = false + var replacementCommittingTailRetained = false + replacementClassification@ while (!initialPublicDeliveryCompleted) { + val candidate = effectiveTicket ?: break + if (candidate === ticket && observedOuterOutcome != null) break + snapshot = residenceSnapshot() + collectorPlan = collectorPlanFor(snapshot, memoryEnvelope, memoryRevision) + plan = collectorPlan.plan + val disposition = candidate.disposition.value + if (disposition is FetchDisposition.InFlight) break + val originalServableBaseline = + candidate === ticket && + reservedCollectorEnvelope != null && + reservedPlan.servesResident && + collectorPlan.plan.servesResident && + collectorPlan.eligibleEnvelope == reservedCollectorEnvelope + if (disposition is FetchDisposition.Committing) { + if (originalServableBaseline) { + deliverDataLocked( + envelope = checkNotNull(reservedCollectorEnvelope), + revision = reservedCollectorRevision, + originOverride = + if ( + canRestampEngineMemoryOrigin( + memoryEnvelope = memoryEnvelope, + memoryRevision = memoryRevision, + currentEnvelope = snapshot.envelope, + currentRevision = snapshot.revision, + ) + ) { + Origin.MEMORY + } else { + null + }, + authority = DataDeliveryAuthority.CollectorBaseline, + ) + } else if ( + collectorPlan.eligibleEnvelope == null || + !collectorPlan.plan.servesResident || + collectorPlan.eligibleEnvelope.value == disposition.attribution.value + ) { + emitLoadingLocked() + } + replacementCommittingTailRetained = true + break@replacementClassification + } + if (++replacementClassifications > 32) { + if ( + collectorPlan.eligibleEnvelope == null || + !collectorPlan.plan.servesResident + ) { + emitLoadingLocked() + } + replacementClassificationCapped = true + break@replacementClassification + } + if (!classifiedReplacementTickets.add(candidate)) { + break@replacementClassification + } + var revalidationReplacementReservedBeforeTail: FetchTicket? = null + when (disposition) { + is FetchDisposition.Committed -> { + if ( + candidate === ticket || + collectorPlan.eligibleEnvelope == null || + !collectorPlan.plan.servesResident || + collectorPlan.eligibleEnvelope.value == + disposition.attribution.value + ) { + emitLoadingLocked() + } else { + replacementCommitRetainedServableRow = true + } + } + + is FetchDisposition.Revalidated -> { + if (snapshot.envelope === disposition.envelope) { + val baseline = launchBaselineFor(candidate, snapshot) + val currentPlan = + planFor( + freshness = freshness, + snapshot = snapshot, + ) + val currentSatisfiesDemand = + revalidatedSatisfiesDemand(snapshot, currentPlan) + // Revalidated publishes before its bookkeeping/outcome tail. The + // old durable status cannot supersede an owner covering this epoch. + val exactOwnerCoversCurrentEpoch = + disposition.envelope.staleEpochAtCommit >= + snapshot.state.staleEpoch + val replacement = + if (currentSatisfiesDemand || exactOwnerCoversCurrentEpoch) { + null + } else { + ensureFetchForCollector( + CollectorFetchPlan( + eligibleEnvelope = baseline.envelope, + eligibleRevision = baseline.revision, + plan = baseline.plan, + currentIsForeignOwner = + snapshot.envelope + ?.directRevalidationOwner != null && + snapshot.envelope !== baseline.envelope, + ), + ) + } + revalidationReplacementReservedBeforeTail = replacement + if ( + currentSatisfiesDemand || + exactOwnerCoversCurrentEpoch || + replacement != null + ) { + if ( + baseline.envelope != null && + baseline.plan.servesResident + ) { + deliverDataLocked( + baseline.envelope, + revision = baseline.revision, + authority = DataDeliveryAuthority.CollectorBaseline, + ) + } else { + emitLoadingLocked() + } + } + } else if ( + collectorPlan.eligibleEnvelope == null || + !collectorPlan.plan.servesResident + ) { + emitLoadingLocked() + } + } + + else -> { + if ( + collectorPlan.eligibleEnvelope == null || + !collectorPlan.plan.servesResident + ) { + emitLoadingLocked() + } + } + } + + val outcome = candidate.outcome.await() + snapshot = residenceSnapshot() + collectorPlan = collectorPlanFor(snapshot, memoryEnvelope, memoryRevision) + plan = collectorPlan.plan + when (outcome) { + is FetchOutcome.Committed -> { + installCommittedWaitLocked(candidate, outcome) + break@replacementClassification + } + + is FetchOutcome.Revalidated -> { + if (revalidationReplacementReservedBeforeTail != null) { + effectiveTicket = revalidationReplacementReservedBeforeTail + initialPublicDeliveryCompleted = true + continue@replacementClassification + } + if (snapshot.envelope !== outcome.envelope) { + effectiveTicket = + if (plan is FetchPlan.Skip) { + null + } else { + ensureFetchForCollector(collectorPlan) + } + continue@replacementClassification + } + when ( + val delivery = + deliverRevalidatedLocked( + outcome = outcome, + watchReplacement = false, + ) + ) { + RevalidatedDelivery.Delivered -> { + effectiveTicket = null + initialPublicDeliveryCompleted = true + } + + RevalidatedDelivery.Obsolete -> { + snapshot = residenceSnapshot() + collectorPlan = + collectorPlanFor(snapshot, memoryEnvelope, memoryRevision) + plan = collectorPlan.plan + effectiveTicket = + if (plan is FetchPlan.Skip) { + null + } else { + ensureFetchForCollector(collectorPlan) + } + } + + is RevalidatedDelivery.Replacement -> { + effectiveTicket = delivery.ticket + initialPublicDeliveryCompleted = + delivery.publicDeliveryCompleted + } + } + } + + is FetchOutcome.Failed -> { + if (residenceAdvancedFrom(candidate, snapshot)) { + enqueueFailureHandoff( + SettledTicketHandoff( + ticket = candidate, + outcome = outcome, + servedStale = publicServedStale, + ), + ) + effectiveTicket = + if (plan is FetchPlan.Skip) { + null + } else { + ensureFetchForCollector(collectorPlan) + } + continue@replacementClassification + } + break@replacementClassification + } + + is FetchOutcome.Deleted -> { + handleOutcomeLocked(candidate, outcome, false) + effectiveTicket = null + initialPublicDeliveryCompleted = true + } + + FetchOutcome.ObsoleteRevalidation, + FetchOutcome.Superseded, + -> { + effectiveTicket = + if (plan is FetchPlan.Skip) { + null + } else { + ensureFetchForCollector(collectorPlan) + } + } + } + } + snapshot = residenceSnapshot() + collectorPlan = collectorPlanFor(snapshot, memoryEnvelope, memoryRevision) + plan = collectorPlan.plan + // A commit can land after the earlier outcome snapshot. Recognize only this + // ticket's exact writer envelope before generic residence delivery so an absent + // start keeps its Loading -> causally observed Data contract. + val finalCommittedDisposition = + effectiveTicket?.disposition?.value as? FetchDisposition.Committed + if ( + awaitingCommitted == null && + finalCommittedDisposition != null && + snapshot.envelope?.matchesWriterAttribution( + finalCommittedDisposition.attribution.value, + finalCommittedDisposition.attribution, + ) == true + ) { + installCommittedWaitLocked( + checkNotNull(effectiveTicket), + finalCommittedDisposition, + ) + } + val currentResidencePlan = + planFor( + freshness = freshness, + snapshot = snapshot, + ) + val reservedBaselineCurrentPlan = + planFor( + freshness = freshness, + snapshot = snapshot, + envelope = reservedCollectorEnvelope, + ) + val memoryOverride = + canRestampEngineMemoryOrigin( + memoryEnvelope = memoryEnvelope, + memoryRevision = memoryRevision, + currentEnvelope = snapshot.envelope, + currentRevision = snapshot.revision, + ) + if (effectiveTicket != null) { + if (watchedTicket !== effectiveTicket) { + watchedTicket = effectiveTicket + servedStaleForWatchedTicket = publicServedStale + } + lastRevalidationRequestedRevision = effectiveTicket.requestRevision + } else if (awaitingCommitted == null) { + watchedTicket = null + } + when { + initialPublicDeliveryCompleted -> Unit + + replacementCommittingTailRetained -> Unit + + // The loop already emitted Loading when the final residence was absent or + // withheld. A policy-servable different row stays silent here until the + // retained effective-ticket watcher classifies its ordered tail. + replacementClassificationCapped -> Unit + + pendingSettledTailAtRecapture && + awaitingCommitted != null && + snapshot.envelope != null && + plan !is FetchPlan.Skip && + plan.servesResident -> Unit + + completedExactRevalidation != null && + completedExactRevalidationDelivery == null && + reservedPlan.servesResident && + reservedBaselineCurrentPlan.servesResident && + reservedCollectorEnvelope != null && + ticket?.residenceRevisionAtLaunch == ticket?.requestRevision && + revalidatedSatisfiesDemand(snapshot, currentResidencePlan) -> + deliverDataLocked( + envelope = reservedCollectorEnvelope, + revision = reservedCollectorRevision, + originOverride = + if ( + canRestampEngineMemoryOrigin( + memoryEnvelope = memoryEnvelope, + memoryRevision = memoryRevision, + currentEnvelope = reservedCollectorEnvelope, + currentRevision = reservedCollectorRevision, + ) + ) { + Origin.MEMORY + } else { + null + }, + authority = DataDeliveryAuthority.CollectorBaseline, + ) + + replacementCommitRetainedServableRow && awaitingCommitted != null -> Unit + + awaitingCommitted != null || + (completedExactRevalidation != null && + completedExactRevalidationDelivery == null) -> + emitLoadingLocked() + + collectorPlan.currentIsForeignOwner && + collectorPlan.eligibleEnvelope != null && + plan.servesResident -> + deliverDataLocked( + envelope = collectorPlan.eligibleEnvelope, + revision = collectorPlan.eligibleRevision, + projectionBase = collectorPlan.eligibleProjectionBase, + originOverride = + if (collectorPlan.eligibleEnvelope === memoryEnvelope) { + Origin.MEMORY + } else { + null + }, + authority = DataDeliveryAuthority.CollectorBaseline, + ) + + collectorPlan.currentIsForeignOwner -> emitLoadingLocked() + + freshness == Freshness.LocalOnly && collectorPlan.eligibleEnvelope == null -> { + if (startupReaderFailure == null) { + if (snapshot.envelope == null) { + deliverAbsenceLocked(snapshot.revision) { + emitLocalOnlyMissingLocked() + } + } else { + emitLocalOnlyMissingLocked() + } + } + } + + collectorPlan.eligibleEnvelope != null && plan.servesResident -> + deliverDataLocked( + envelope = collectorPlan.eligibleEnvelope, + revision = collectorPlan.eligibleRevision, + projectionBase = collectorPlan.eligibleProjectionBase, + originOverride = if (memoryOverride) Origin.MEMORY else null, + authority = + if ( + memoryOverride || + collectorPlan.eligibleEnvelope.directRevalidationOwner != null + ) { + DataDeliveryAuthority.CollectorBaseline + } else { + DataDeliveryAuthority.Generic + }, + ) + + else -> { + if (snapshot.envelope == null && startupReaderFailure == null) { + deliverAbsenceLocked(snapshot.revision) { emitLoadingLocked() } + } else { + emitLoadingLocked() + } + } + } + startupReaderFailure?.let { emitErrorLocked(it) } + if ( + !replacementClassificationCapped && + !replacementCommittingTailRetained && + awaitingCommitted == null && + effectiveTicket?.disposition?.value !is FetchDisposition.Revalidated && + effectiveTicket?.disposition?.value !is FetchDisposition.Committing && + effectiveTicket?.disposition?.value !is FetchDisposition.Committed + ) { + flushPendingFailureHandoffsLocked() + } + InitialDelivery(snapshot, plan, effectiveTicket) + } + + suspend fun deliverRevalidated( + outcome: FetchOutcome.Revalidated, + ): RevalidatedDelivery = + mutex.withLock { + deliverRevalidatedLocked(outcome, watchReplacement = false) + } + + /** Rechecks policy after a 304 because epochs and wall-clock age may advance meanwhile. */ + private suspend fun deliverRevalidatedLocked( + outcome: FetchOutcome.Revalidated, + watchReplacement: Boolean, + ): RevalidatedDelivery { + var snapshot = residenceSnapshot() + if (snapshot.envelope !== outcome.envelope) return RevalidatedDelivery.Obsolete + var plan = + planFor( + freshness = freshness, + snapshot = snapshot, + ) + var demandSatisfied = revalidatedSatisfiesDemand(snapshot, plan) + + var replacement: FetchTicket? = null + if (!demandSatisfied) { + replacement = + ensureFetchForCollector( + CollectorFetchPlan( + eligibleEnvelope = snapshot.envelope, + eligibleRevision = snapshot.revision, + plan = plan, + currentIsForeignOwner = false, + ), + ) + if (replacement != null) { + if (watchReplacement) { + watchTicketLocked(replacement) + } else { + watchedTicket = replacement + servedStaleForWatchedTicket = publicServedStale + lastRevalidationRequestedRevision = replacement.requestRevision + } + } + snapshot = residenceSnapshot() + if (snapshot.envelope !== outcome.envelope) { + return replacement?.let { + RevalidatedDelivery.Replacement( + ticket = it, + publicDeliveryCompleted = false, + ) + } + ?: RevalidatedDelivery.Obsolete + } + plan = + planFor( + freshness = freshness, + snapshot = snapshot, + ) + demandSatisfied = revalidatedSatisfiesDemand(snapshot, plan) + } + + if ( + replacement != null && + replacement.disposition.value !is FetchDisposition.InFlight + ) { + return RevalidatedDelivery.Replacement( + ticket = replacement, + publicDeliveryCompleted = false, + ) + } + + serverDeletionObserved = false + val envelope = checkNotNull(snapshot.envelope) + when { + demandSatisfied -> + if ( + !emitRevalidatedLocked( + outcome = outcome, + envelope = envelope, + projectionBase = + snapshot.projectionBase?.takeIf { captured -> + captured.envelope === outcome.envelope && + captured.revision == outcome.residenceRevision + }, + ) + ) { + return RevalidatedDelivery.Obsolete + } + + plan.servesResident -> + deliverDataLocked( + envelope, + revision = outcome.residenceRevision, + projectionBase = + snapshot.projectionBase?.takeIf { captured -> + captured.envelope === outcome.envelope && + captured.revision == outcome.residenceRevision + }, + authority = DataDeliveryAuthority.OwnerOutcome, + ) + else -> emitLoadingLocked() + } + return replacement?.let { + RevalidatedDelivery.Replacement( + ticket = it, + publicDeliveryCompleted = true, + ) + } + ?: RevalidatedDelivery.Delivered + } + + /** Publishes the exact owner's 304 as a lifecycle signal and adopts its fresh envelope. */ + private suspend fun emitRevalidatedLocked( + outcome: FetchOutcome.Revalidated, + envelope: ValueEnvelope, + projectionBase: ProjectionBase?, + ): Boolean { + if (overlay == null) { + producer.send(StoreResult.Revalidated(outcome.age)) + telemetry?.onServe(key, envelope.origin) + lastDataFingerprint = DataFingerprint(envelope) + lastConfirmedRevision = outcome.residenceRevision + publicHasValue = true + loadingVisible = false + localOnlyMissingEmitted = false + publicServedStale = false + servedStaleForWatchedTicket = false + terminalFailedDemand = null + lastRevalidationRequestedRevision = outcome.residenceRevision + return true + } + + beforeProjectionAuthorizationTestGate() + val authorizationBase = + projectionBase?.also { captured -> + check( + captured.envelope === envelope && + captured.revision == outcome.residenceRevision, + ) { + "A captured projection base must name the revalidated residence." + } + } ?: ProjectionBase(envelope, outcome.residenceRevision) + val authorization = + projectionAuthorization( + base = authorizationBase, + originOverride = null, + authority = DataDeliveryAuthority.OwnerOutcome, + ) + val previousAuthorization = projectionAuthorization + projectionAuthorization = authorization + val readiness = awaitProjectionReadiness(authorization) + if (readiness is ProjectionReadiness.Obsolete) { + revokeObsoleteProjectionAuthorization(authorization, previousAuthorization) + return false + } + val projection = (readiness as ProjectionReadiness.Ready).projection + val revalidatedOrigin = + when (projection) { + is Projection.Value -> { + val previousValue = + lastDataFingerprint?.let { fingerprint -> + if (fingerprint.isOverlaid) { + fingerprint.overlaidValue + } else { + fingerprint.envelope?.value + } + } + if (!publicHasValue || previousValue != envelope.value) { + deliverConfirmedDataLocked( + envelope = envelope, + revision = outcome.residenceRevision, + authority = DataDeliveryAuthority.OwnerOutcome, + ) + } else { + lastDataFingerprint = DataFingerprint(envelope) + lastConfirmedRevision = outcome.residenceRevision + publicHasValue = true + loadingVisible = false + localOnlyMissingEmitted = false + publicServedStale = false + servedStaleForWatchedTicket = false + } + envelope.origin + } + + is Projection.Overlaid -> { + renderOverlaidLocked(authorization, projection.value) + Origin.OVERLAY + } + + Projection.Absent -> { + emitLoadingLocked(preserveProjectionAuthorization = true) + null + } + } + revalidatedOrigin?.let { telemetry?.onServe(key, it) } + terminalFailedDemand = null + lastRevalidationRequestedRevision = outcome.residenceRevision + producer.send(StoreResult.Revalidated(outcome.age)) + return true + } + + private fun revalidatedSatisfiesDemand( + snapshot: ResidenceSnapshot, + plan: FetchPlan, + ): Boolean = this@KeyEngine.revalidatedSatisfiesDemand(freshness, snapshot, plan) + + private fun ReaderRecord.Row.snapshot( + resolution: ReaderResolution, + ): ResidenceSnapshot = + ResidenceSnapshot( + state = resolution.state, + envelope = envelope, + revision = residenceRevision, + status = resolution.status, + nowEpochMillis = resolution.nowEpochMillis, + projectionBase = resolution.projectionBase, + ) + + /** Keeps an equal late reader replay inside the demand already settled by a failure. */ + private fun failedDemandStillCovers(snapshot: ResidenceSnapshot): Boolean { + val failedTicket = terminalFailedDemand ?: return false + if (residenceAdvancedFrom(failedTicket, snapshot)) { + terminalFailedDemand = null + return false + } + lastRevalidationRequestedRevision = snapshot.revision + return true + } + + suspend fun clearInitialTicket(ticket: FetchTicket) { + mutex.withLock { + if (watchedTicket === ticket) watchedTicket = null + if (awaitingCommitted?.ticket === ticket) awaitingCommitted = null + if (handledCommittedTicket === ticket) handledCommittedTicket = null + } + } + + suspend fun retainCommittedTicket( + ticket: FetchTicket, + outcome: FetchOutcome.Committed, + ) { + mutex.withLock { retainCommittedTicketLocked(ticket, outcome) } + } + + suspend fun deliverTerminalError(exception: StoreException) { + mutex.withLock { emitErrorLocked(exception, servedStaleOverride = false) } + } + + suspend fun deliverTerminalOutcome(outcome: FetchOutcome) { + mutex.withLock { surfaceTerminalOutcomeLocked(outcome, servedStaleForTicket = false) } + } + + /** Starts the configured projection observer once, including before a MustBeFresh wait. */ + fun startProjectionObserver() { + val snapshots = projectionSnapshot ?: return + if (projectionObserverStarted) return + projectionObserverStarted = true + producer.launch { + snapshots.collect { snapshot -> + if ( + snapshot is ProjectionSnapshot.Ready || + snapshot is ProjectionSnapshot.Terminal + ) { + beforeProjectionDeliveryLockTestGate() + mutex.withLock { + beforeProjectionDeliveryTestGate() + deliverProjectionSnapshotLocked(snapshot) + afterProjectionDeliveryTestGate() + } + } + } + } + } + + fun start( + planningEpoch: Long, + initialTicket: FetchTicket?, + initialPlan: FetchPlan, + ) { + if (initialTicket != null) { + watchedTicket = initialTicket + if (initialPlan !is FetchPlan.Skip) { + lastRevalidationRequestedRevision = initialTicket.requestRevision + } + observeCommittedDisposition(initialTicket) + } + + producer.launch { + readerRecords.collect { record -> deliverRecord(record) } + } + startProjectionObserver() + producer.launch { + initialTicket?.let { ticket -> + val outcome = awaitTicketOutcome(ticket) + beforeTicketOutcomeDeliveryTestGate() + mutex.withLock { + deliverWatchedOutcomeLocked(ticket, outcome) + } + } + state.staleEpochsAfter(planningEpoch).collect { + mutex.withLock { + serverDeletionObserved = false + requestAndDeliverLocked( + forceRequest = true, + suppressResidentIfVisible = true, + ) + } + } + } + } + + private suspend fun deliverRecord(record: ReaderRecord) { + beforeReaderDeliveryLockTestGate(record) + mutex.withLock { + beforeReaderDeliveryTestGate() + if (record !is ReaderRecord.Failure) latestReaderRecord = record + deliverReaderRecordLocked(record) + } + } + + /** Resolves and delivers one pipeline notification without leaving the delivery mutex. */ + private suspend fun deliverReaderRecordLocked(record: ReaderRecord) { + val resolution = resolveCurrentRecord(record) ?: return + when (val resolved = resolution.record) { + is ReaderRecord.Failure -> emitErrorLocked(resolved.exception) + is ReaderRecord.Row -> + deliverRowLocked( + notification = record, + initialResolution = resolution, + ) + + is ReaderRecord.Absent -> + deliverAbsentLocked(record as ReaderRecord.Absent) + } + } + + /** Plans a current row, reserving work before delivery and rechecking after suspension. */ + private suspend fun deliverRowLocked( + notification: ReaderRecord, + initialResolution: ReaderResolution, + ) { + installCompletedCommittedWaitIfNeededLocked() + var resolution = initialResolution + var row = resolution.record as ReaderRecord.Row + suppressMissingUntilReaderRecovery = false + serverDeletionObserved = false + + val committedWait = awaitingCommitted + if (committedWait != null && !isCausallyCurrent(notification, committedWait)) return + val authorizedByCommittedWait = committedWait != null + if (committedWait != null) clearCommittedWaitLocked(committedWait) + + val observedTicket = watchedTicket + val observedOutcome = + observedTicket + ?.takeIf { it.outcome.isCompleted } + ?.let { awaitTicketOutcome(it) } + if ( + observedTicket != null && + row.envelope.directRevalidationOwner === observedTicket && + (notification as? ReaderRecord.Row)?.envelope?.value == row.envelope.value + ) { + // A replay mapped before this collector's 304 must not replace its exact fresh + // owner while the ordered watcher is still responsible for direct delivery. + return + } + var collectorPlan = + collectorPlanFor( + snapshot = row.snapshot(resolution), + eligibleBaseline = readerEligibleBaseline(notification, row.envelope), + ) + var rowPlan = collectorPlan.plan + var demandSatisfied = + envelopeSatisfiesDemand( + collectorPlan.eligibleEnvelope, + resolution.state, + rowPlan, + ) + if (demandSatisfied) { + terminalFailedDemand = null + lastRevalidationRequestedRevision = row.residenceRevision + } else { + failedDemandStillCovers(row.snapshot(resolution)) + } + + val settledTicket = observedTicket + val completedSettledOutcome = + observedOutcome?.takeIf { outcome -> + settledTicket?.let { ticket -> + if (outcome is FetchOutcome.Failed) { + residenceAdvancedFrom( + ticket, + row.snapshot(resolution), + ) + } else { + ticket.requestRevision != row.residenceRevision + } + } == true + } + if (completedSettledOutcome is FetchOutcome.Deleted) { + if (watchedTicket === settledTicket) watchedTicket = null + handleOutcomeLocked( + checkNotNull(settledTicket), + completedSettledOutcome, + servedStaleForWatchedTicket, + ) + serverDeletionObserved = false + } + + val pendingTicket = watchedTicket + val pendingSlot = resolution.state.fetch as? FetchSlot.InFlight + val durableWriterRowReady = + demandSatisfied && + ( + authorizedByCommittedWait || + pendingTicket?.let { + isOwnDurablyCommittedRow(notification, it) + } == true + ) + if ( + pendingTicket != null && + pendingTicket === settledTicket && + observedOutcome == null && + pendingSlot?.ticket !== pendingTicket && + !durableWriterRowReady + ) { + // Slot settlement can precede ordered persistence/bookkeeping tails. Retain every + // other row until the exact outcome can install its causal or replacement handoff. + return + } + + if ( + rowPlan !is FetchPlan.Skip && + !demandSatisfied && + settledTicket != null && + completedSettledOutcome != null && + completedSettledOutcome !is FetchOutcome.Deleted + ) { + val outcome = completedSettledOutcome + if (outcome !is FetchOutcome.Committed) { + val oldServedStale = servedStaleForWatchedTicket + if (outcome is FetchOutcome.Failed) { + enqueueFailureHandoff( + SettledTicketHandoff( + ticket = settledTicket, + outcome = outcome, + servedStale = oldServedStale, + ), + ) + } + val replacement = ensureFetchForCollector(collectorPlan) + if (replacement != null) { + watchTicketLocked(replacement) + } else if (watchedTicket === settledTicket) { + watchedTicket = null + } + + // The replacement reservation can suspend. Only the exact row that caused + // the handoff may now be served as refreshing. + val current = resolveCurrentRecord(notification) + val currentRow = current?.record as? ReaderRecord.Row + if (current == null || currentRow == null || !isSameResolvedRow(row, currentRow)) { + return + } + resolution = current + row = currentRow + collectorPlan = + collectorPlanFor( + snapshot = row.snapshot(resolution), + eligibleBaseline = readerEligibleBaseline(notification, row.envelope), + ) + rowPlan = collectorPlan.plan + demandSatisfied = + envelopeSatisfiesDemand( + collectorPlan.eligibleEnvelope, + resolution.state, + rowPlan, + ) + if (demandSatisfied) { + terminalFailedDemand = null + lastRevalidationRequestedRevision = row.residenceRevision + } else { + failedDemandStillCovers(row.snapshot(resolution)) + } + } + } + + if ( + rowPlan !is FetchPlan.Skip && + !demandSatisfied && + watchedTicket == null && + lastRevalidationRequestedRevision != row.residenceRevision + ) { + ensureFetchForCollector(collectorPlan)?.let(::watchTicketLocked) + + // ensureFetch can suspend while another observation wins. The original row is + // only deliverable if generation, residence revision, and content all survived. + val current = resolveCurrentRecord(notification) ?: return + val currentRow = current.record as? ReaderRecord.Row ?: return + if (!isSameResolvedRow(row, currentRow)) return + resolution = current + row = currentRow + collectorPlan = + collectorPlanFor( + snapshot = row.snapshot(resolution), + eligibleBaseline = readerEligibleBaseline(notification, row.envelope), + ) + rowPlan = collectorPlan.plan + demandSatisfied = + envelopeSatisfiesDemand( + collectorPlan.eligibleEnvelope, + resolution.state, + rowPlan, + ) + if (demandSatisfied) { + terminalFailedDemand = null + lastRevalidationRequestedRevision = row.residenceRevision + } else { + failedDemandStillCovers(row.snapshot(resolution)) + } + } + + val eligibleEnvelope = collectorPlan.eligibleEnvelope + when { + demandSatisfied && eligibleEnvelope != null -> + deliverDataLocked( + eligibleEnvelope, + revision = collectorPlan.eligibleRevision, + projectionBase = collectorPlan.eligibleProjectionBase, + authority = + if (eligibleEnvelope.directRevalidationOwner != null) { + DataDeliveryAuthority.CollectorBaseline + } else { + DataDeliveryAuthority.Generic + }, + ) + + rowPlan.servesResident && eligibleEnvelope != null -> + deliverDataLocked( + eligibleEnvelope, + revision = collectorPlan.eligibleRevision, + projectionBase = collectorPlan.eligibleProjectionBase, + authority = + if (eligibleEnvelope.directRevalidationOwner != null) { + DataDeliveryAuthority.CollectorBaseline + } else { + DataDeliveryAuthority.Generic + }, + ) + else -> emitLoadingLocked() + } + flushPendingFailureHandoffsLocked() + } + + /** Defers an absence already owned by an in-flight commit or authoritative delete outcome. */ + private suspend fun deliverAbsentLocked(notification: ReaderRecord.Absent) { + val pendingTicket = watchedTicket + // The authoritative Deleted outcome owns exact-absence projection and its terminal + // Missing error. A restarted reader can observe the committed null first; letting that + // record replan here would insert Loading between the optimistic projection and error. + if (pendingTicket?.disposition?.value == FetchDisposition.Deleted) return + if (pendingTicket != null && !pendingTicket.outcome.isCompleted) { + val committing = + pendingTicket.disposition.value as? FetchDisposition.Committing + if ( + committing != null && + notification.consumedAttribution !== committing.attribution && + notification.activeWriteAttributionAtObservation !== committing.attribution + ) { + return + } + } + installCompletedCommittedWaitIfNeededLocked() + val committedWait = awaitingCommitted + if (committedWait != null && !isCausallyCurrent(notification, committedWait)) return + if (committedWait != null) clearCommittedWaitLocked(committedWait) + suppressMissingUntilReaderRecovery = false + if (publicHasValue || freshness != Freshness.LocalOnly) { + deliverAbsenceLocked(notification.residenceRevision) { emitLoadingLocked() } + } + if (pendingFailureHandoffs.isNotEmpty()) { + flushPendingFailureHandoffsLocked() + } + failedDemandStillCovers(residenceSnapshot()) + requestAndDeliverLocked(forceRequest = false) + flushPendingFailureHandoffsLocked() + } + + private fun enqueueFailureHandoff(handoff: SettledTicketHandoff) { + if (pendingFailureHandoffs.none { it.ticket === handoff.ticket }) { + pendingFailureHandoffs.addLast(handoff) + } + } + + private suspend fun flushPendingFailureHandoffsLocked() { + while (pendingFailureHandoffs.isNotEmpty()) { + val handoff = pendingFailureHandoffs.removeFirst() + surfaceTerminalOutcomeLocked(handoff.outcome, handoff.servedStale) + } + } + + private suspend fun deliverCurrentPlanStateLocked() { + while (true) { + val snapshot = residenceSnapshot() + val collectorPlan = collectorPlanFor(snapshot) + val eligibleEnvelope = collectorPlan.eligibleEnvelope + val decision = + if (eligibleEnvelope != null && collectorPlan.plan.servesResident) { + deliverDataLocked( + eligibleEnvelope, + revision = collectorPlan.eligibleRevision, + projectionBase = collectorPlan.eligibleProjectionBase, + authority = + if (eligibleEnvelope.directRevalidationOwner != null) { + DataDeliveryAuthority.CollectorBaseline + } else { + DataDeliveryAuthority.Generic + }, + ) + } else if (snapshot.envelope == null && startupReaderFailure == null) { + deliverAbsenceLocked(snapshot.revision) { emitLoadingLocked() } + } else { + emitLoadingLocked() + return + } + if (decision != DataDeliveryDecision.ObsoleteProjection) { + return + } + } + } + + private fun envelopeSatisfiesDemand( + envelope: ValueEnvelope?, + state: KeyState, + plan: FetchPlan, + ): Boolean { + if (envelope == null) return false + return when (freshness) { + Freshness.CachedOrFetch, + Freshness.StaleIfError, + Freshness.MustBeFresh, + -> + isEngineConfirmedEnvelope(envelope) && + envelope.meta != null && + envelope.staleEpochAtCommit >= state.staleEpoch + + Freshness.LocalOnly, + is Freshness.MaxAge, + -> plan is FetchPlan.Skip + } + } + + /** Authorizes only the exact writer row after durable return, never a CAS fallback. */ + private fun isOwnDurablyCommittedRow( + notification: ReaderRecord, + ticket: FetchTicket, + ): Boolean { + val row = notification as? ReaderRecord.Row ?: return false + val disposition = ticket.disposition.value as? FetchDisposition.Committed + ?: return false + if (row.envelope.value != disposition.attribution.value) return false + return row.consumedAttribution === disposition.attribution || + row.activeWriteAttributionAtObservation === disposition.attribution + } + + /** Installs a completed commit before the current reader notification is classified. */ + private suspend fun installCompletedCommittedWaitIfNeededLocked() { + val ticket = watchedTicket ?: return + if (handledCommittedTicket === ticket) return + if (!ticket.outcome.isCompleted) return + val outcome = awaitTicketOutcome(ticket) + if (outcome !is FetchOutcome.Committed) return + + installCommittedWaitLocked(ticket, outcome) + } + + /** True only when this raw observation belongs at or after the completed write. */ + private fun isCausallyCurrent( + notification: ReaderRecord, + wait: CommittedReaderWait, + ): Boolean { + val rawSequence = + when (notification) { + is ReaderRecord.Row -> notification.rawObservationSequence + is ReaderRecord.Absent -> notification.rawObservationSequence + is ReaderRecord.Failure -> return false + } + if ( + notification.readerGen == wait.rawReaderGen && + rawSequence <= wait.rawCommitCutoff + ) { + return wait.authoritativeRawSequence != null && + rawSequence == wait.authoritativeRawSequence + } + return when (notification) { + is ReaderRecord.Row -> notification.successfulWriteSequenceAtObservation >= + wait.successfulWriteSequenceAtOutcome + + is ReaderRecord.Absent -> notification.successfulWriteSequenceAtObservation >= + wait.successfulWriteSequenceAtOutcome + + is ReaderRecord.Failure -> false + } + } + + private suspend fun installCommittedWaitLocked( + ticket: FetchTicket, + outcome: FetchOutcome.Committed, + ) { + installCommittedWaitLocked( + ticket = ticket, + successfulWriteSequence = outcome.successfulWriteSequence, + attribution = outcome.attribution, + rawReaderGen = outcome.rawReaderGen, + rawCommitCutoff = outcome.rawCommitCutoff, + authoritativeRawSequence = outcome.authoritativeRawSequence, + ) + } + + private suspend fun installCommittedWaitLocked( + ticket: FetchTicket, + disposition: FetchDisposition.Committed, + ) { + installCommittedWaitLocked( + ticket = ticket, + successfulWriteSequence = disposition.successfulWriteSequence, + attribution = disposition.attribution, + rawReaderGen = disposition.rawReaderGen, + rawCommitCutoff = disposition.rawCommitCutoff, + authoritativeRawSequence = disposition.authoritativeRawSequence, + ) + } + + private fun installCommittedWaitLocked( + ticket: FetchTicket, + successfulWriteSequence: Long, + attribution: AttributionTag, + rawReaderGen: Long, + rawCommitCutoff: Long, + authoritativeRawSequence: Long?, + ) { + handledCommittedTicket = ticket + awaitingCommitted = + CommittedReaderWait( + ticket = ticket, + successfulWriteSequenceAtOutcome = successfulWriteSequence, + attribution = attribution, + rawReaderGen = rawReaderGen, + rawCommitCutoff = rawCommitCutoff, + authoritativeRawSequence = authoritativeRawSequence, + ) + } + + private suspend fun retainCommittedTicketLocked( + ticket: FetchTicket, + outcome: FetchOutcome.Committed, + ) { + if (watchedTicket !== ticket) return + installCommittedWaitLocked(ticket, outcome) + when (val latest = latestReaderRecord) { + is ReaderRecord.Row, + is ReaderRecord.Absent, + -> deliverReaderRecordLocked(latest) + + null, + is ReaderRecord.Failure, + -> Unit + } + } + + private suspend fun reprocessLatestReaderRecordLocked() { + when (val latest = latestReaderRecord) { + is ReaderRecord.Row, + is ReaderRecord.Absent, + -> deliverReaderRecordLocked(latest) + + null, + is ReaderRecord.Failure, + -> Unit + } + } + + private fun clearCommittedWaitLocked(wait: CommittedReaderWait) { + if (awaitingCommitted === wait) awaitingCommitted = null + if (wait.ticket.outcome.isCompleted) { + if (watchedTicket === wait.ticket) watchedTicket = null + if (handledCommittedTicket === wait.ticket) handledCommittedTicket = null + } + } + + private suspend fun requestAndDeliverLocked( + forceRequest: Boolean, + suppressResidentIfVisible: Boolean = false, + ) { + if (awaitingCommitted != null) return + val observedTicket = watchedTicket + val observedOutcome = + observedTicket + ?.takeIf { it.outcome.isCompleted } + ?.let { awaitTicketOutcome(it) } + if (observedOutcome != null) { + if (observedOutcome is FetchOutcome.Committed) { + retainCommittedTicketLocked(checkNotNull(observedTicket), observedOutcome) + if (awaitingCommitted != null) return + } else { + return + } + } + var snapshot = residenceSnapshot() + var collectorPlan = collectorPlanFor(snapshot) + var plan = collectorPlan.plan + if (forceRequest) { + terminalFailedDemand = null + } else if ( + plan is FetchPlan.Skip || + envelopeSatisfiesDemand( + collectorPlan.eligibleEnvelope, + snapshot.state, + plan, + ) + ) { + terminalFailedDemand = null + } else { + failedDemandStillCovers(snapshot) + } + + if (freshness == Freshness.LocalOnly) { + val envelope = collectorPlan.eligibleEnvelope + if (envelope != null) { + deliverDataLocked( + envelope, + revision = collectorPlan.eligibleRevision, + projectionBase = collectorPlan.eligibleProjectionBase, + authority = + if (envelope.directRevalidationOwner != null) { + DataDeliveryAuthority.CollectorBaseline + } else { + DataDeliveryAuthority.Generic + }, + ) + } else if (!suppressMissingUntilReaderRecovery) { + if (overlay != null && snapshot.envelope == null) { + deliverAbsenceLocked(snapshot.revision) { + if (publicHasValue) emitLoadingLocked() + emitLocalOnlyMissingLocked() + } + } else { + if (publicHasValue) emitLoadingLocked() + emitLocalOnlyMissingLocked() + } + } + return + } + + var ticket: FetchTicket? = null + if ( + plan !is FetchPlan.Skip && + watchedTicket == null && + !(serverDeletionObserved && snapshot.envelope == null) && + (forceRequest || lastRevalidationRequestedRevision != snapshot.revision) + ) { + ticket = ensureFetchForCollector(collectorPlan) + ticket?.let(::watchTicketLocked) + snapshot = residenceSnapshot() + collectorPlan = collectorPlanFor(snapshot) + plan = collectorPlan.plan + } + + val eligibleEnvelope = collectorPlan.eligibleEnvelope + when { + eligibleEnvelope != null && plan.servesResident -> { + val sameResidenceAlreadyVisible = + publicHasValue && + lastDataFingerprint == DataFingerprint(eligibleEnvelope) + if (!suppressResidentIfVisible || !sameResidenceAlreadyVisible) { + deliverDataLocked( + eligibleEnvelope, + revision = collectorPlan.eligibleRevision, + projectionBase = collectorPlan.eligibleProjectionBase, + authority = + if (eligibleEnvelope.directRevalidationOwner != null) { + DataDeliveryAuthority.CollectorBaseline + } else { + DataDeliveryAuthority.Generic + }, + ) + } else { + refreshVisibleStaleOwnershipLocked() + } + } + + eligibleEnvelope != null && plan is FetchPlan.Skip -> + deliverDataLocked( + eligibleEnvelope, + revision = collectorPlan.eligibleRevision, + projectionBase = collectorPlan.eligibleProjectionBase, + authority = + if (eligibleEnvelope.directRevalidationOwner != null) { + DataDeliveryAuthority.CollectorBaseline + } else { + DataDeliveryAuthority.Generic + }, + ) + + plan !is FetchPlan.Skip -> emitLoadingLocked() + else -> emitLocalOnlyMissingLocked() + } + + ticket?.let(::watchTicketLocked) + } + + private fun watchTicketLocked(ticket: FetchTicket) { + // ensureFetch may join work launched against an older residence. Associate this + // collector with the actual ticket boundary so a newer revision can replan later. + terminalFailedDemand = null + lastRevalidationRequestedRevision = ticket.requestRevision + if (watchedTicket === ticket) return + watchedTicket = ticket + servedStaleForWatchedTicket = publicServedStale + observeCommittedDisposition(ticket) + producer.launch { + val outcome = awaitTicketOutcome(ticket) + beforeTicketOutcomeDeliveryTestGate() + mutex.withLock { + deliverWatchedOutcomeLocked(ticket, outcome) + } + } + } + + /** Wakes a retained writer row at durable return, before ordered bookkeeping completes. */ + private fun observeCommittedDisposition(ticket: FetchTicket) { + producer.launch { + val terminal = + ticket.disposition.first { + it !is FetchDisposition.InFlight && + it !is FetchDisposition.Committing + } + val committed = terminal as? FetchDisposition.Committed ?: return@launch + mutex.withLock { + if ( + watchedTicket === ticket && + handledCommittedTicket !== ticket + ) { + installCommittedWaitLocked(ticket, committed) + reprocessLatestReaderRecordLocked() + } + } + } + } + + private suspend fun deliverWatchedOutcomeLocked( + ticket: FetchTicket, + outcome: FetchOutcome, + ) { + if (watchedTicket !== ticket) return + if ( + outcome is FetchOutcome.Committed && + handledCommittedTicket === ticket + ) { + if (awaitingCommitted?.ticket === ticket) return + watchedTicket = null + reprocessLatestReaderRecordLocked() + if (handledCommittedTicket === ticket) handledCommittedTicket = null + return + } + var servedStaleForTicket = servedStaleForWatchedTicket + if (outcome is FetchOutcome.Failed) { + val advancedResidence = residenceAdvancedFrom(ticket, residenceSnapshot()) + if (advancedResidence) { + enqueueFailureHandoff( + SettledTicketHandoff( + ticket = ticket, + outcome = outcome, + servedStale = servedStaleForTicket, + ), + ) + } + reprocessLatestReaderRecordLocked() + if (watchedTicket !== ticket) return + if (advancedResidence) { + watchedTicket = null + val snapshot = residenceSnapshot() + val collectorPlan = collectorPlanFor(snapshot) + val plan = collectorPlan.plan + if (plan !is FetchPlan.Skip) { + val replacement = ensureFetchForCollector(collectorPlan) + replacement?.let(::watchTicketLocked) + if (replacement == null) { + deliverCurrentPlanStateLocked() + flushPendingFailureHandoffsLocked() + } + } else { + flushPendingFailureHandoffsLocked() + } + return + } + if (pendingFailureHandoffs.isNotEmpty()) { + deliverCurrentPlanStateLocked() + flushPendingFailureHandoffsLocked() + servedStaleForTicket = servedStaleForWatchedTicket + } + } + if (outcome !is FetchOutcome.Committed) watchedTicket = null + handleOutcomeLocked(ticket, outcome, servedStaleForTicket) + if ( + outcome !is FetchOutcome.Committed && + outcome !is FetchOutcome.Failed + ) { + reprocessLatestReaderRecordLocked() + flushPendingFailureHandoffsLocked() + } + } + + private suspend fun handleOutcomeLocked( + ticket: FetchTicket, + outcome: FetchOutcome, + servedStaleForTicket: Boolean, + ) { + when (outcome) { + is FetchOutcome.Committed -> + retainCommittedTicketLocked(ticket, outcome) + + is FetchOutcome.Revalidated -> { + if ( + deliverRevalidatedLocked(outcome, watchReplacement = true) == + RevalidatedDelivery.Obsolete + ) { + requestAndDeliverLocked(forceRequest = true) + } + } + + FetchOutcome.ObsoleteRevalidation -> + requestAndDeliverLocked(forceRequest = true) + + is FetchOutcome.Failed -> { + surfaceTerminalOutcomeLocked(outcome, servedStaleForTicket) + val snapshot = residenceSnapshot() + if (residenceAdvancedFrom(ticket, snapshot)) { + requestAndDeliverLocked(forceRequest = false) + } else { + terminalFailedDemand = ticket + } + } + + is FetchOutcome.Deleted -> { + serverDeletionObserved = true + surfaceTerminalOutcomeLocked(outcome, servedStaleForTicket) + } + + FetchOutcome.Superseded -> requestAndDeliverLocked(forceRequest = true) + } + } + + private suspend fun surfaceTerminalOutcomeLocked( + outcome: FetchOutcome, + servedStaleForTicket: Boolean, + ) { + when (outcome) { + is FetchOutcome.Failed -> { + if (overlay != null) { + deliverCurrentPlanStateLocked() + } + emitErrorLocked( + outcome.exception, + servedStaleOverride = + if (overlay == null) servedStaleForTicket else publicServedStale, + ) + } + + is FetchOutcome.Deleted -> { + if (overlay == null) { + emitLoadingLocked() + } else { + val rendered = + deliverAbsenceLocked(outcome.residenceRevision) { + emitLoadingLocked() + } + if (rendered == DataDeliveryDecision.ObsoleteProjection) { + deliverCurrentPlanStateLocked() + } + } + emitErrorLocked(serverDeletedException(), servedStaleOverride = false) + } + + else -> error("Only terminal outcomes can be surfaced by this helper: $outcome") + } + } + + private suspend fun deliverDataLocked( + envelope: ValueEnvelope, + revision: Long, + projectionBase: ProjectionBase? = null, + originOverride: Origin? = null, + authority: DataDeliveryAuthority = DataDeliveryAuthority.Generic, + ): DataDeliveryDecision { + if (overlay == null) { + return deliverConfirmedDataLocked( + envelope = envelope, + revision = revision, + originOverride = originOverride, + authority = authority, + ) + } + val existingAuthorization = projectionAuthorization + if ( + envelope.directRevalidationOwner != null && + authority == DataDeliveryAuthority.Generic && + !( + publicHasValue && + existingAuthorization?.base?.envelope === envelope && + existingAuthorization.base.revision == revision + ) + ) { + refreshVisibleStaleOwnershipLocked() + return DataDeliveryDecision.ForeignDirectRevalidation + } + + beforeProjectionAuthorizationTestGate() + val authorizationBase = + projectionBase?.also { captured -> + check(captured.envelope === envelope && captured.revision == revision) { + "A captured projection base must name the delivered residence." + } + } ?: ProjectionBase(envelope, revision) + val authorization = + projectionAuthorization( + base = authorizationBase, + originOverride = originOverride, + authority = authority, + ) + val previousAuthorization = projectionAuthorization + projectionAuthorization = authorization + return when (val readiness = awaitProjectionReadiness(authorization)) { + is ProjectionReadiness.Ready -> + renderProjectionLocked( + authorization = authorization, + projection = readiness.projection, + ) + + ProjectionReadiness.Obsolete -> { + revokeObsoleteProjectionAuthorization(authorization, previousAuthorization) + DataDeliveryDecision.ObsoleteProjection + } + } + } + + /** Landed confirmed-value renderer, also used by pass-through projection intent. */ + private suspend fun deliverConfirmedDataLocked( + envelope: ValueEnvelope, + revision: Long, + originOverride: Origin? = null, + authority: DataDeliveryAuthority = DataDeliveryAuthority.Generic, + ): DataDeliveryDecision { + val fingerprint = + DataFingerprint( + envelope = envelope, + ) + if ( + envelope.directRevalidationOwner != null && + authority == DataDeliveryAuthority.Generic && + !(publicHasValue && lastDataFingerprint == fingerprint) + ) { + refreshVisibleStaleOwnershipLocked() + return DataDeliveryDecision.ForeignDirectRevalidation + } + val snapshot = state.value + val refreshing = snapshot.fetch is FetchSlot.InFlight + val data = + toData( + envelope = envelope, + freshness = freshness, + originOverride = originOverride, + refreshingOverride = refreshing, + ) + if (lastDataFingerprint == fingerprint && publicHasValue) { + lastConfirmedRevision = revision + publicServedStale = data.isStale && staleServingTolerated(freshness) + if (watchedTicket != null) { + servedStaleForWatchedTicket = publicServedStale + } + return DataDeliveryDecision.AlreadyVisible + } + producer.send(data) + telemetry?.onServe(key, data.origin) + lastDataFingerprint = fingerprint + lastConfirmedRevision = revision + publicHasValue = true + loadingVisible = false + localOnlyMissingEmitted = false + publicServedStale = data.isStale && staleServingTolerated(freshness) + if (watchedTicket != null) { + servedStaleForWatchedTicket = publicServedStale + } + return DataDeliveryDecision.Delivered + } + + /** Authorizes exact confirmed absence and renders only its matching ready projection. */ + private suspend fun deliverAbsenceLocked( + revision: Long, + absent: suspend () -> Unit, + ): DataDeliveryDecision { + if (overlay == null) { + absent() + return DataDeliveryDecision.Absent + } + val authorization = + projectionAuthorization( + base = ProjectionBase(envelope = null, revision = revision), + originOverride = null, + authority = DataDeliveryAuthority.Generic, + ) + val previousAuthorization = projectionAuthorization + projectionAuthorization = authorization + return when (val readiness = awaitProjectionReadiness(authorization)) { + is ProjectionReadiness.Ready -> + renderProjectionLocked( + authorization = authorization, + projection = readiness.projection, + ) + + ProjectionReadiness.Obsolete -> { + revokeObsoleteProjectionAuthorization(authorization, previousAuthorization) + DataDeliveryDecision.ObsoleteProjection + } + } + } + + /** Builds an exact authorization and targets any already-accepted matching generation. */ + private fun projectionAuthorization( + base: ProjectionBase, + originOverride: Origin?, + authority: DataDeliveryAuthority, + ): ProjectionAuthorization { + val currentBase = checkNotNull(projectionResidence).value.base + val authorizedBase = currentBase.takeIf { it.matches(base) } ?: base + val snapshot = checkNotNull(projectionSnapshot).value + val targetGeneration = + when (snapshot) { + is ProjectionSnapshot.Pending -> + snapshot.generation.takeIf { snapshot.base.matches(authorizedBase) } + + is ProjectionSnapshot.Ready -> + snapshot.generation.takeIf { snapshot.base.matches(authorizedBase) } + + is ProjectionSnapshot.Terminal -> snapshot.generation + ProjectionSnapshot.Uninitialized -> null + } ?: 0L + return ProjectionAuthorization( + base = authorizedBase, + originOverride = originOverride, + authority = authority, + targetGeneration = targetGeneration, + ) + } + + /** Waits on snapshot and residence flows only; no engine lock is reacquired. */ + private suspend fun awaitProjectionReadiness( + authorization: ProjectionAuthorization, + ): ProjectionReadiness { + val snapshots = checkNotNull(projectionSnapshot) + val residences = checkNotNull(projectionResidence) + while (true) { + val residence = residences.value + if (!residence.base.matches(authorization.base)) { + return ProjectionReadiness.Obsolete + } + val snapshot = snapshots.value + when (snapshot) { + is ProjectionSnapshot.Terminal -> + throw OverlayProjectionException(snapshot.failure) + + is ProjectionSnapshot.Pending -> + if (snapshot.base.matches(authorization.base)) { + authorization.targetGeneration = + maxOf(authorization.targetGeneration, snapshot.generation) + } + + is ProjectionSnapshot.Ready -> + if ( + snapshot.base.matches(authorization.base) && + snapshot.generation >= authorization.targetGeneration + ) { + authorization.targetGeneration = + maxOf(authorization.targetGeneration, snapshot.generation) + return ProjectionReadiness.Ready(snapshot.projection) + } + + ProjectionSnapshot.Uninitialized -> Unit + } + + val observedSnapshot = snapshot + val observedResidence = residence + beforeProjectionReadinessWaitTestGate() + combine(snapshots, residences) { nextSnapshot, nextResidence -> + nextSnapshot to nextResidence + }.first { (nextSnapshot, nextResidence) -> + nextSnapshot !== observedSnapshot || nextResidence !== observedResidence + } + } + } + + /** Applies one projection through the collector's retained policy/render context. */ + private suspend fun renderProjectionLocked( + authorization: ProjectionAuthorization, + projection: Projection, + ): DataDeliveryDecision = + when (projection) { + is Projection.Value -> + deliverConfirmedDataLocked( + envelope = checkNotNull(authorization.base.envelope), + revision = authorization.base.revision, + originOverride = authorization.originOverride, + authority = authorization.authority, + ) + + is Projection.Overlaid -> renderOverlaidLocked(authorization, projection.value) + Projection.Absent -> { + emitLoadingLocked(preserveProjectionAuthorization = true) + DataDeliveryDecision.Absent + } + } + + /** Renders overlay-created data and derives stale-error posture from the authorized base. */ + private suspend fun renderOverlaidLocked( + authorization: ProjectionAuthorization, + value: V, + ): DataDeliveryDecision { + val fingerprint = + DataFingerprint( + envelope = authorization.base.envelope, + overlaidValue = value, + isOverlaid = true, + ) + val baseServedStale = + authorization.base.envelope?.let { envelope -> + toData( + envelope = envelope, + freshness = freshness, + originOverride = authorization.originOverride, + ).isStale && staleServingTolerated(freshness) + } == true + val samePublicProjection = + publicHasValue && + lastDataFingerprint?.isOverlaid == true && + lastDataFingerprint?.overlaidValue == value + if (samePublicProjection) { + lastDataFingerprint = fingerprint + lastConfirmedRevision = authorization.base.revision + publicServedStale = baseServedStale + if (watchedTicket != null) servedStaleForWatchedTicket = publicServedStale + return DataDeliveryDecision.AlreadyVisible + } + val data = + StoreResult.Data( + value = value, + origin = Origin.OVERLAY, + age = Duration.ZERO, + isStale = false, + refreshing = state.value.fetch is FetchSlot.InFlight, + ) + producer.send(data) + telemetry?.onServe(key, Origin.OVERLAY) + lastDataFingerprint = fingerprint + lastConfirmedRevision = authorization.base.revision + publicHasValue = true + loadingVisible = false + localOnlyMissingEmitted = false + publicServedStale = baseServedStale + if (watchedTicket != null) servedStaleForWatchedTicket = publicServedStale + return DataDeliveryDecision.Delivered + } + + /** Reprojects an active authorization or applies the referential foreign-owner exception. */ + private suspend fun deliverProjectionSnapshotLocked(snapshot: ProjectionSnapshot) { + if (checkNotNull(projectionSnapshot).value !== snapshot) return + if (snapshot is ProjectionSnapshot.Terminal) { + throw OverlayProjectionException(snapshot.failure) + } + val ready = snapshot as? ProjectionSnapshot.Ready ?: return + val authorization = projectionAuthorization ?: return + val currentBase = checkNotNull(projectionResidence).value.base + if (!ready.base.matches(currentBase)) return + if (ready.generation < authorization.targetGeneration) return + when { + ready.base.matches(authorization.base) -> + renderProjectionLocked(authorization, ready.projection) + + ready.base.isConfirmFreshAuthorizationSuccessorOf(authorization.base) -> { + val successorAuthorization = + ProjectionAuthorization( + base = ready.base, + originOverride = authorization.originOverride, + authority = authorization.authority, + targetGeneration = + maxOf(authorization.targetGeneration, ready.generation), + ) + projectionAuthorization = successorAuthorization + renderProjectionLocked(successorAuthorization, ready.projection) + } + + publicHasValue && + ready.base.envelope?.directRevalidationOwner != null && + authorization.base.envelope != null && + ready.base.envelope.value === authorization.base.envelope.value -> + renderProjectionLocked(authorization, ready.projection) + } + } + + private fun revokeObsoleteProjectionAuthorization( + authorization: ProjectionAuthorization, + previousAuthorization: ProjectionAuthorization?, + ) { + if (projectionAuthorization !== authorization) return + val currentBase = checkNotNull(projectionResidence).value.base + if (currentBase.isConfirmFreshAuthorizationSuccessorOf(authorization.base)) return + val current = currentBase.envelope + projectionAuthorization = + previousAuthorization?.takeIf { previous -> + current?.directRevalidationOwner != null && + publicHasValue && + lastDataFingerprint?.envelope === previous.base.envelope && + lastConfirmedRevision == previous.base.revision + } + } + + private fun refreshVisibleStaleOwnershipLocked() { + val visibleEnvelope = lastDataFingerprint?.envelope + publicServedStale = + publicHasValue && + visibleEnvelope != null && + toData( + envelope = visibleEnvelope, + freshness = freshness, + ).isStale && + staleServingTolerated(freshness) + if (watchedTicket != null) { + servedStaleForWatchedTicket = publicServedStale + } + } + + private suspend fun emitLoadingLocked( + preserveProjectionAuthorization: Boolean = false, + ) { + if (!preserveProjectionAuthorization) projectionAuthorization = null + if (loadingVisible) return + producer.send(StoreResult.Loading()) + loadingVisible = true + publicHasValue = false + publicServedStale = false + servedStaleForWatchedTicket = false + lastDataFingerprint = null + } + + private suspend fun emitLocalOnlyMissingLocked() { + if (localOnlyMissingEmitted) return + producer.send( + StoreResult.Error( + error = localOnlyMissingException().error, + servedStale = false, + ), + ) + localOnlyMissingEmitted = true + publicHasValue = false + loadingVisible = false + publicServedStale = false + servedStaleForWatchedTicket = false + lastDataFingerprint = null + } + + private suspend fun emitErrorLocked( + exception: StoreException, + servedStaleOverride: Boolean? = null, + ) { + producer.send( + StoreResult.Error( + error = exception.error, + servedStale = servedStaleOverride ?: publicServedStale, + ), + ) + } + } + + /** Returns a value according to policy, hydrating persistence before planning on a miss. */ + internal suspend fun get(freshness: Freshness): V { + ensureOpen() + while (true) { + var snapshot = residenceSnapshot() + if (snapshot.envelope == null) snapshot = hydrateFromSot() + val envelope = snapshot.envelope + val plan = planFor(freshness, snapshot) + + if (plan is FetchPlan.Skip) { + return envelope?.let { serve(it.value, it.origin) } + ?: throw localOnlyMissingException() + } + + if ( + envelope != null && + plan.servesResident && + freshness != Freshness.StaleIfError + ) { + ensureFetch(freshness) + return serve(envelope.value, envelope.origin) + } + + val ticket = ensureFetch(freshness) ?: continue + val outcome = ticket.outcome.await() + beforeTicketOutcomeDeliveryTestGate() + when (outcome) { + is FetchOutcome.Committed -> + return serve(committedValue(outcome), outcome.attribution.origin) + + is FetchOutcome.Revalidated -> { + val snapshot = residenceSnapshot() + if (snapshot.envelope === outcome.envelope) { + val plan = + planFor( + freshness = freshness, + snapshot = snapshot, + ) + if (revalidatedSatisfiesDemand(freshness, snapshot, plan)) { + snapshot.envelope?.let { return serve(it.value, it.origin) } + } + } + } + + FetchOutcome.ObsoleteRevalidation -> Unit + + is FetchOutcome.Failed -> + if (freshness == Freshness.StaleIfError && envelope != null) { + return serve(envelope.value, envelope.origin) + } else { + throw outcome.exception + } + + FetchOutcome.Superseded -> throw supersededException() + is FetchOutcome.Deleted -> throw serverDeletedException() + } + } + } + + @Suppress("UNCHECKED_CAST") + private fun committedValue(outcome: FetchOutcome.Committed): V = outcome.value as V + + /** Reports one successful caller-context serve without altering the returned value. */ + private fun serve( + value: V, + origin: Origin, + ): V { + telemetry?.onServe(key, origin) + return value + } + + /** Renders nullable metadata conservatively while retaining saturating age behavior. */ + private fun toData( + envelope: ValueEnvelope, + freshness: Freshness, + originOverride: Origin? = null, + refreshingOverride: Boolean? = null, + ): StoreResult.Data { + val snapshot = state.value + val meta = envelope.meta + val age = elapsedAge(wallClock.nowEpochMillis(), meta) + val epochStale = envelope.staleEpochAtCommit < snapshot.staleEpoch + val ageStale = freshness is Freshness.MaxAge && meta != null && age > freshness.notOlderThan + return StoreResult.Data( + value = envelope.value, + origin = originOverride ?: envelope.origin, + age = age, + isStale = meta == null || epochStale || ageStale, + refreshing = refreshingOverride ?: (snapshot.fetch is FetchSlot.InFlight), + ) + } + + private fun fetchException(failure: Throwable): StoreException { + val message = + "Fetch failed for key '${keyId.namespace}/${keyId.canonicalId}': ${failure.message}. " + + "The fetcher threw; inspect the cause for the underlying failure." + return StoreException(StoreError.Fetch(message, failure), failure) + } + + private fun fetchResultException(failure: Throwable): StoreException { + val message = + "Fetch failed for key '${keyId.namespace}/${keyId.canonicalId}': ${failure.message}. " + + "The fetcher returned FetcherResult.Error; inspect the cause for the " + + "underlying failure." + return StoreException(StoreError.Fetch(message, failure), failure) + } + + private fun readerException(failure: Throwable): StoreException { + val message = + "Reading the source of truth failed for key " + + "'${keyId.namespace}/${keyId.canonicalId}': ${failure.message}. " + + "Durable data could not be observed; inspect the cause and retry the read." + return StoreException(StoreError.Persistence(message, failure), failure) + } + + private fun writeException(failure: Throwable): StoreException { + val message = + "Persisting the fetched value failed for key " + + "'${keyId.namespace}/${keyId.canonicalId}': ${failure.message}. " + + "The fetch succeeded but the source of truth rejected the write; inspect the " + + "cause and retry the read." + return StoreException(StoreError.Persistence(message, failure), failure) + } + + private fun writeHandleException(failure: Throwable): StoreException { + val message = + "Write-handle apply failed for key '${keyId.namespace}/${keyId.canonicalId}': " + + "${failure.message}. The source of truth rejected the write; state is unchanged. " + + "Inspect the cause and retry." + return StoreException( + error = StoreError.Persistence(message = message, cause = failure), + cause = failure, + ) + } + + private fun clearPersistenceException(failure: Throwable): StoreException { + val message = + "clear() failed for key '${keyId.namespace}/${keyId.canonicalId}': the source of " + + "truth delete threw: ${failure.message}. The row may still exist; inspect the " + + "cause and retry clear()." + return StoreException(StoreError.Persistence(message, failure), failure) + } + + /** Wraps a durable per-key maintenance failure with typed persistence context. */ + private fun maintenancePersistenceException( + operation: String, + failure: Throwable, + ): StoreException { + val message = + "Durable $operation failed for key '${keyId.namespace}/${keyId.canonicalId}': " + + "${failure.message}. The operation did not complete; retry it and inspect the " + + "cause for the underlying persistence failure." + return StoreException( + error = StoreError.Persistence(message = message, cause = failure), + cause = failure, + ) + } + + private fun serverDeletePersistenceException(failure: Throwable): StoreException { + val message = + "Applying the server-side deletion failed for key " + + "'${keyId.namespace}/${keyId.canonicalId}': the source of truth delete threw: " + + "${failure.message}. The row may still exist; the deletion was not applied — " + + "retry the read." + return StoreException(StoreError.Persistence(message, failure), failure) + } + + private fun supersededException(): StoreException { + val message = + "Could not return a value for key '${keyId.namespace}/${keyId.canonicalId}': clear() " + + "removed the key while its fetch was in flight, so the fetched value was " + + "discarded. The value is currently missing; retry the read to trigger a fresh " + + "fetch." + return StoreException(StoreError.Missing(key, message)) + } + + private fun serverDeletedException(): StoreException { + val message = + "Could not return a value for key '${keyId.namespace}/${keyId.canonicalId}': the " + + "fetcher reported that the server deleted this value, so the local copy was " + + "removed. Recreate the value upstream or treat Missing as the empty state." + return StoreException(StoreError.Missing(key, message)) + } + + private fun localOnlyMissingException(): StoreException { + val message = + "Could not return a value for key '${keyId.namespace}/${keyId.canonicalId}': " + + "Freshness.LocalOnly forbids fetching and no local value exists. Seed the key " + + "with another policy first or handle StoreError.Missing as the empty state." + return StoreException(StoreError.Missing(key, message)) + } + + private fun notModifiedWithoutValueException(): StoreException { + val message = + "Could not return a value for key '${keyId.namespace}/${keyId.canonicalId}': the " + + "fetcher returned FetcherResult.NotModified but no local value exists to " + + "revalidate. Return FetcherResult.Success with a full value when the client has " + + "no cached copy." + return StoreException(StoreError.Missing(key, message)) + } + + private fun ensureOpen() { + if (!engineJob.isActive) throw storeClosedException() + } + + private data class ResidenceSnapshot( + val state: KeyState, + val envelope: ValueEnvelope?, + val revision: Long, + val status: KeyStatus?, + val nowEpochMillis: Long, + val projectionBase: ProjectionBase?, + ) + + private data class PlannedFetchEffect( + val effect: KeyEffect, + val collectorEligibleResidence: ValueEnvelope?, + val collectorEligibleRevision: Long, + val plan: FetchPlan, + ) + + private data class FetchReservation( + val ticket: FetchTicket, + val collectorEligibleResidence: ValueEnvelope?, + val collectorEligibleRevision: Long, + val plan: FetchPlan, + ) + + /** Marks an engine-side raw-stamping defect so the adapter retry boundary rethrows it. */ + private class RawObservationFailure( + val engineFailure: Throwable, + ) : RuntimeException(engineFailure) + + /** Restarts the sole reader after discarding one unresolved committed-fence mismatch. */ + private class RestartRawReaderSession : RuntimeException() + + /** Converts self-originated flow cancellation into a terminal no-failure-contract breach. */ + private class ProjectionChangesFailure( + val projectionCause: CancellationException, + ) : RuntimeException(projectionCause) + + /** Raw observations made while one exact SoT write is active. */ + private sealed interface ActiveRawPhase { + data object Unobserved : ActiveRawPhase + + class OtherBeforeMatching( + val observation: RawWriteObservation, + ) : ActiveRawPhase + + class Matching( + val observation: RawWriteObservation, + val attribution: AttributionTag, + ) : ActiveRawPhase + + class OtherAfterMatching( + val matchingObservation: RawWriteObservation, + val observation: RawWriteObservation, + ) : ActiveRawPhase + } + + /** One source-ordered nullable adapter row captured before pipeline conflation. */ + private data class RawWriteObservation( + val readerGen: Long, + val rawSequence: Long, + val value: Any?, + val attributionAtObservation: AttributionTag?, + val activeWriteAttributionAtObservation: AttributionTag?, + val successfulWriteSequenceAtObservation: Long, + ) { + /** Returns only the exact active writer tag under the value-bound fallback rule. */ + fun matchingWriterAttribution(): AttributionTag? { + val active = activeWriteAttributionAtObservation ?: return null + val observed = attributionAtObservation + return when { + value == null -> null + observed != null -> + active.takeIf { observed === active && observed.value == value } + active.value == value -> active + else -> null + } + } + } + + private fun ActiveRawPhase.matchingObservationOrNull(): RawWriteObservation? = + when (this) { + ActiveRawPhase.Unobserved -> null + is ActiveRawPhase.OtherBeforeMatching -> null + is ActiveRawPhase.Matching -> observation + is ActiveRawPhase.OtherAfterMatching -> matchingObservation + } + + private data class WriteObservationBoundary( + val readerGen: Long, + val observedAttribution: AttributionTag?, + val activeAttribution: AttributionTag?, + val successfulSequence: Long, + val latestRawSequence: Long, + val activeRawPhase: ActiveRawPhase, + val readerSession: Long, + val readerSessionActive: Boolean, + val pendingWriteAttribution: AttributionTag?, + ) + + private data class ClosedWriteBoundary( + val readerGen: Long, + val rawCommitCutoff: Long, + val phase: ActiveRawPhase, + val successfulWriteSequence: Long, + ) + + private data class DurableWriteResolution( + val successfulWriteSequence: Long, + val readerGen: Long, + val rawCommitCutoff: Long, + val authoritativeRawSequence: Long?, + ) + + private data class RawCommitResolution( + val readerGen: Long, + val rawCommitCutoff: Long, + val authoritativeRawSequence: Long?, + val residenceRevision: Long, + val envelope: ValueEnvelope?, + val consumedAttribution: AttributionTag?, + ) + + private data class PreparedReaderRow( + val consumedAttribution: AttributionTag?, + val ownerAttribution: AttributionTag, + val matchingAttribution: AttributionTag?, + val dropNonmatchingOnCommit: Boolean, + ) + + private data class ReaderResolution( + val record: ReaderRecord, + val state: KeyState, + val status: KeyStatus?, + val nowEpochMillis: Long, + val projectionBase: ProjectionBase?, + ) + + private data class InitialDelivery( + val snapshot: ResidenceSnapshot, + val plan: FetchPlan, + val ticket: FetchTicket?, + ) + + private data class CollectorFetchPlan( + val eligibleEnvelope: ValueEnvelope?, + val eligibleRevision: Long, + val plan: FetchPlan, + val currentIsForeignOwner: Boolean, + val eligibleProjectionBase: ProjectionBase? = null, + ) + + private data class TicketLaunchBaseline( + val envelope: ValueEnvelope?, + val revision: Long, + val plan: FetchPlan, + ) + + private data class EligibleBaseline( + val envelope: ValueEnvelope?, + val revision: Long, + ) + + private class ProjectionAuthorization( + val base: ProjectionBase, + val originOverride: Origin?, + val authority: DataDeliveryAuthority, + var targetGeneration: Long, + ) + + private data class TicketLaunchBaselineEntry( + val ticket: FetchTicket, + val baseline: TicketLaunchBaseline, + ) + + private enum class DataDeliveryAuthority { + Generic, + CollectorBaseline, + OwnerOutcome, + } + + private enum class DataDeliveryDecision { + Delivered, + AlreadyVisible, + ForeignDirectRevalidation, + Absent, + ObsoleteProjection, + } + + private sealed interface ProjectionReadiness { + class Ready( + val projection: Projection, + ) : ProjectionReadiness + + data object Obsolete : ProjectionReadiness + } + + private sealed interface RevalidatedDelivery { + data object Delivered : RevalidatedDelivery + + data object Obsolete : RevalidatedDelivery + + data class Replacement( + val ticket: FetchTicket, + val publicDeliveryCompleted: Boolean, + ) : RevalidatedDelivery + } + + private data class SettledTicketHandoff( + val ticket: FetchTicket, + val outcome: FetchOutcome, + val servedStale: Boolean, + ) + + private data class CommittedReaderWait( + val ticket: FetchTicket, + val successfulWriteSequenceAtOutcome: Long, + val attribution: AttributionTag, + val rawReaderGen: Long, + val rawCommitCutoff: Long, + val authoritativeRawSequence: Long?, + ) + + private data class DataFingerprint( + val envelope: ValueEnvelope?, + val overlaidValue: V? = null, + val isOverlaid: Boolean = false, + ) +} + +/** Opaque causal identity preserved only across metadata-only residence successors. */ +private class ProjectionAuthorizationLineage + +/** Exact residence identity and revision used by one projection attempt. */ +private class ProjectionBase( + val envelope: ValueEnvelope?, + val revision: Long, + val authorizationLineage: ProjectionAuthorizationLineage? = null, +) { + fun matches(other: ProjectionBase): Boolean = + envelope === other.envelope && revision == other.revision + + fun isConfirmFreshAuthorizationSuccessorOf(previous: ProjectionBase): Boolean { + val currentEnvelope = envelope ?: return false + val previousEnvelope = previous.envelope ?: return false + val lineage = authorizationLineage ?: return false + if (lineage !== previous.authorizationLineage) return false + if (currentEnvelope === previousEnvelope) return false + if (revision <= previous.revision) return false + if (currentEnvelope.value != previousEnvelope.value) return false + if (currentEnvelope.origin != previousEnvelope.origin) return false + if (currentEnvelope.meta == null || currentEnvelope.meta === previousEnvelope.meta) { + return false + } + if (currentEnvelope.staleEpochAtCommit < previousEnvelope.staleEpochAtCommit) return false + return currentEnvelope.directRevalidationOwner == null + } +} + +/** Immediate latest-residence signal used to obsolete readiness waits without an engine lock. */ +private class ProjectionResidence( + val base: ProjectionBase, +) + +/** Latest state of the one engine-owned projection writer. */ +private sealed interface ProjectionSnapshot { + data object Uninitialized : ProjectionSnapshot + + class Pending( + val base: ProjectionBase, + val generation: Long, + ) : ProjectionSnapshot + + class Ready( + val base: ProjectionBase, + val generation: Long, + val projection: Projection, + ) : ProjectionSnapshot + + class Terminal( + val generation: Long, + val failure: Throwable, + ) : ProjectionSnapshot +} + +/** MEMORY is honest only while the exact envelope and its monotone revision remain current. */ +internal fun canRestampMemoryOrigin( + memoryEnvelope: ValueEnvelope?, + memoryRevision: Long, + currentEnvelope: ValueEnvelope?, + currentRevision: Long, +): Boolean = + memoryEnvelope != null && + currentEnvelope === memoryEnvelope && + currentRevision == memoryRevision diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyId.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyId.kt new file mode 100644 index 000000000..38cb4b310 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyId.kt @@ -0,0 +1,23 @@ +package org.mobilenativefoundation.store6.core.internal + +import org.mobilenativefoundation.store6.core.StoreKey + +/** + * Canonical registry identity for a store key. + * + * Keeping namespace and canonical identifier as separate fields avoids relying on a key + * implementation's equality contract and avoids collisions caused by string concatenation. + */ +internal data class KeyId( + val namespace: String, + val canonicalId: String, +) { + companion object { + /** Captures the canonical identity exposed by [key]. */ + fun from(key: StoreKey): KeyId = + KeyId( + namespace = key.namespace.value, + canonicalId = key.canonicalId(), + ) + } +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyRegistry.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyRegistry.kt new file mode 100644 index 000000000..1502a1d1c --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyRegistry.kt @@ -0,0 +1,192 @@ +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.mobilenativefoundation.store6.core.StoreKey + +/** + * Owns at most one [KeyEngine] per canonical [KeyId], refcounted, with quiescent-only LRU idling. + * + * Structure: + * - [active] holds engines with refCount >= 1 or a not-yet-idled zero-ref engine awaiting its + * fetch reference release; [idle] holds only quiescent zero-ref engines in LRU (insertion) + * order. The maps are disjoint. Eviction reads only [idle], and acquisition removes from + * [idle] under the same lock, so evicting a held engine is unrepresentable. + * - References: every withEngine bracket (stream collection lifetime, get/maintenance call + * duration), every bulk-maintenance sweep entry, and every fetch job (via + * [EngineResidencyHooks]) holds one reference. + * - Eviction destroys only derived state; durable truth lives in the source of truth and + * bookkeeper, so a recreated engine is semantically identical: hydration restamps freshness + * from the bookkeeper's persisted status. Total resident engines <= active references + maxIdle. + * - Bulk sweeps retain each snapshotted engine for the action's duration, preserving the + * double-sweep-under-fence semantics: watermarks cover engines missed by a snapshot; an engine + * inserted between bulk-clear sweeps is included by purge or remains fenced until maintenance + * releases. + * - Creation still runs [verifyStableCanonicalId] once per residency. + */ +internal class KeyRegistry( + private val maxIdle: Int, + private val createEngine: (K, KeyId, EngineResidencyHooks) -> KeyEngine, +) { + private class Handle( + val engine: KeyEngine, + ) { + var refCount: Int = 0 + } + + private val lock = Mutex() + private val active = HashMap>() + private val idle = LinkedHashMap>() + private var closed = false + private var created = 0L + private var destroyed = 0L + + internal suspend fun withEngine( + key: K, + block: suspend (KeyEngine) -> R, + ): R { + val id = KeyId.from(key) + val handle = + lock.withLock { + if (closed) throw storeClosedException() + val resolved = + active[id] + ?: idle.remove(id)?.also { revived -> active[id] = revived } + ?: Handle(newEngine(key, id)).also { fresh -> + active[id] = fresh + created += 1 + } + resolved.refCount += 1 + resolved + } + try { + return block(handle.engine) + } finally { + withContext(NonCancellable) { release(id) } + } + } + + internal suspend fun forEachResident( + namespace: String?, + action: suspend (KeyEngine) -> Unit, + ) { + snapshotAndForEachResident(namespace, action) + } + + /** Returns the exact retained snapshot acted on when a caller must notify it after a fence. */ + internal suspend fun snapshotAndForEachResident( + namespace: String?, + action: suspend (KeyEngine) -> Unit, + ): List> { + val retained = + lock.withLock { + if (closed) return emptyList() + val ids = + (active.keys + idle.keys).filter { id -> + namespace == null || id.namespace == namespace + } + ids.map { id -> + val handle = active[id] ?: checkNotNull(idle.remove(id)).also { active[id] = it } + handle.refCount += 1 + id to handle + } + } + try { + retained.forEach { (_, handle) -> action(handle.engine) } + } finally { + withContext(NonCancellable) { retained.forEach { (id, _) -> release(id) } } + } + return retained.map { (_, handle) -> handle.engine } + } + + /** Releases one reference; at zero references a quiescent engine idles and overflow evicts. */ + private suspend fun release(id: KeyId) { + val overflow = + lock.withLock { + val handle = active[id] ?: return@withLock emptyList() + handle.refCount -= 1 + if (handle.refCount > 0) return@withLock emptyList() + // A non-quiescent zero-ref engine holds an in-flight fetch whose own reference + // release re-runs this check, so it deliberately stays active until then. + if (!handle.engine.isQuiescentForIdle()) return@withLock emptyList() + active.remove(id) + if (maxIdle == 0) { + destroyed += 1 + return@withLock listOf(handle.engine) + } + idle[id] = handle + val evicted = ArrayList>() + while (idle.size > maxIdle) { + val eldestId = idle.keys.first() + val eldest = idle.remove(eldestId) ?: break + destroyed += 1 + evicted += eldest.engine + } + evicted + } + overflow.forEach { engine -> engine.destroy() } + } + + /** + * Drops residency state at close. Every critical section is non-suspending, so the lock is + * effectively always free here; the caller also re-invokes this from the store job's + * completion handler. Present behavior: if a concurrent non-suspending section holds the lock + * at both attempts, the maps are released when the store becomes unreachable — entries are + * bounded by resident engines at close and every engine scope is already cancelled. + */ + internal fun clearOnClose() { + if (!lock.tryLock()) return + try { + closed = true + active.clear() + idle.clear() + } finally { + lock.unlock() + } + } + + internal suspend fun residentCountForTest(): Int = lock.withLock { active.size + idle.size } + + internal suspend fun idleCountForTest(): Int = lock.withLock { idle.size } + + internal suspend fun createdCountForTest(): Long = lock.withLock { created } + + internal suspend fun destroyedCountForTest(): Long = lock.withLock { destroyed } + + private fun newEngine( + key: K, + id: KeyId, + ): KeyEngine { + verifyStableCanonicalId(key, id) + return createEngine(key, id, HandleHooks(id)) + } + + private inner class HandleHooks(private val id: KeyId) : EngineResidencyHooks { + override suspend fun retainFetchRef() { + lock.withLock { + val handle = active[id] ?: idle.remove(id)?.also { active[id] = it } ?: return + handle.refCount += 1 + } + } + + override suspend fun releaseFetchRef() { + withContext(NonCancellable) { release(id) } + } + } + + private fun verifyStableCanonicalId( + key: K, + id: KeyId, + ) { + val second = key.canonicalId() + check(id.canonicalId == second) { + "StoreKey ${key::class.simpleName ?: ""} in namespace " + + "'${id.namespace}' returned two different canonical ids for the same " + + "instance ('${id.canonicalId}', then '$second'). canonicalId() is the key's " + + "durable identity and must be a pure, stable function of the key's immutable " + + "fields. Fix the key type so repeated calls always return the same string." + } + } +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyState.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyState.kt new file mode 100644 index 000000000..cdf7dc9f5 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyState.kt @@ -0,0 +1,49 @@ +package org.mobilenativefoundation.store6.core.internal + +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreMeta + +/** + * Consume-once attribution owned by the exact fetch [owner] for a future pipeline emission. + * + * This tag applies only when a future pipeline row `==` [value]. A different or absent emission + * consumes and discards it; equal external content is indistinguishable residue. Matching writer + * rows remain outside residence until [owner] publishes an exact durable commit disposition. + */ +internal class AttributionTag( + val owner: FetchTicket, + val value: Any, + val origin: Origin, + val meta: StoreMeta, + val staleEpochAtCommit: Long, +) + +/** Immutable state governing fetch ownership and invalidation epochs for one canonical key. */ +internal data class KeyState( + /** The current fetch slot for the key. */ + val fetch: FetchSlot, + + /** Monotone count of invalidations; a resident value is stale when committed under a lower epoch. */ + val staleEpoch: Long, + + /** Monotone count of clears; guards fetch commits so a pre-clear response can never resurrect. */ + val clearEpoch: Long, + + /** Monotone generation of the future source-of-truth reader pipeline. */ + val readerGen: Long, + + /** Consume-once provenance for a future pipeline emission matching its stamped value. */ + val attribution: AttributionTag?, +) { + companion object { + /** State for a key with no active fetch and no invalidation history. */ + val Initial: KeyState = + KeyState( + fetch = FetchSlot.Idle, + staleEpoch = 0L, + clearEpoch = 0L, + readerGen = 0L, + attribution = null, + ) + } +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/MaintenanceCoordinator.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/MaintenanceCoordinator.kt new file mode 100644 index 000000000..8055c4262 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/MaintenanceCoordinator.kt @@ -0,0 +1,171 @@ +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext + +private const val REENTRY_MESSAGE = + "MaintenanceCoordinator callbacks cannot re-enter the same coordinator." + +private class MaintenanceCallbackContext( + val coordinator: MaintenanceCoordinator, + val parent: MaintenanceCallbackContext?, +) : AbstractCoroutineContextElement(Key) { + fun contains(candidate: MaintenanceCoordinator): Boolean { + var current: MaintenanceCallbackContext? = this + while (current != null) { + if (current.coordinator === candidate) return true + current = current.parent + } + return false + } + + companion object Key : CoroutineContext.Key +} + +internal class MaintenanceCoordinator { + private val stateMutex = Mutex() + private val maintenanceMutex = Mutex() + private val wakeVersion = MutableStateFlow(0L) + private val activeCommits = mutableMapOf() + private val blockedNamespaces = mutableSetOf() + private var globalMaintenance = false + + suspend fun withCommit( + namespace: String, + block: suspend () -> T, + ): T { + val callbackContext = callbackContextForEntry() + acquireCommit(namespace) + try { + return withContext(callbackContext) { block() } + } finally { + withContext(NonCancellable) { + releaseCommit(namespace) + } + } + } + + suspend fun withNamespaceMaintenance( + namespace: String, + block: suspend () -> T, + ): T = + withMaintenance( + namespace = namespace, + callbackContext = callbackContextForEntry(), + block = block, + ) + + suspend fun withGlobalMaintenance(block: suspend () -> T): T = + withMaintenance( + namespace = null, + callbackContext = callbackContextForEntry(), + block = block, + ) + + private suspend fun callbackContextForEntry(): MaintenanceCallbackContext { + val parent = currentCoroutineContext()[MaintenanceCallbackContext] + check(parent?.contains(this) != true) { REENTRY_MESSAGE } + return MaintenanceCallbackContext(coordinator = this, parent = parent) + } + + private suspend fun acquireCommit(namespace: String) { + while (true) { + var admitted = false + val observedVersion = + stateMutex.withLock { + if (!globalMaintenance && namespace !in blockedNamespaces) { + activeCommits[namespace] = activeCommits.getOrElse(namespace) { 0 } + 1 + advanceVersion() + admitted = true + } + wakeVersion.value + } + if (admitted) return + wakeVersion.first { it != observedVersion } + } + } + + private suspend fun releaseCommit(namespace: String) { + stateMutex.withLock { + val active = checkNotNull(activeCommits[namespace]) + if (active == 1) { + activeCommits.remove(namespace) + } else { + activeCommits[namespace] = active - 1 + } + advanceVersion() + } + } + + private suspend fun withMaintenance( + namespace: String?, + callbackContext: MaintenanceCallbackContext, + block: suspend () -> T, + ): T { + maintenanceMutex.lock() + var scopeBlocked = false + try { + blockScope(namespace) + scopeBlocked = true + awaitDrain(namespace) + return withContext(callbackContext) { block() } + } finally { + withContext(NonCancellable) { + if (scopeBlocked) { + unblockScope(namespace) + } + maintenanceMutex.unlock() + } + } + } + + private suspend fun blockScope(namespace: String?) { + stateMutex.withLock { + if (namespace == null) { + globalMaintenance = true + } else { + check(blockedNamespaces.add(namespace)) + } + advanceVersion() + } + } + + private suspend fun awaitDrain(namespace: String?) { + while (true) { + val observedVersion = + stateMutex.withLock { + val drained = + if (namespace == null) { + activeCommits.isEmpty() + } else { + namespace !in activeCommits + } + if (drained) null else wakeVersion.value + } + if (observedVersion == null) return + wakeVersion.first { it != observedVersion } + } + } + + private suspend fun unblockScope(namespace: String?) { + stateMutex.withLock { + if (namespace == null) { + globalMaintenance = false + } else { + check(blockedNamespaces.remove(namespace)) + } + advanceVersion() + } + } + + private fun advanceVersion() { + wakeVersion.value += 1L + } +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/Projection.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/Projection.kt new file mode 100644 index 000000000..393efccb9 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/Projection.kt @@ -0,0 +1,25 @@ +package org.mobilenativefoundation.store6.core.internal + +/** The private overlay projection vocabulary retained by the engine writer. */ +internal sealed interface Projection { + /** Pass-through intent; collector-local authorization supplies the render context. */ + data class Value( + val envelope: ValueEnvelope, + ) : Projection + + /** A value created or changed by the overlay. */ + data class Overlaid( + val value: V, + ) : Projection + + /** Projected absence. */ + data object Absent : Projection +} + +/** Deterministic failure surfaced by every configured stream after projection terminalization. */ +internal class OverlayProjectionException( + cause: Throwable, +) : IllegalStateException( + "Overlay projection failed for this key; apply and changes are no-failure contracts.", + (cause as? OverlayProjectionException)?.cause ?: cause, + ) diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/ReaderRecord.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/ReaderRecord.kt new file mode 100644 index 000000000..9f7fee1e2 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/ReaderRecord.kt @@ -0,0 +1,108 @@ +package org.mobilenativefoundation.store6.core.internal + +import org.mobilenativefoundation.store6.core.StoreException + +/** One observation from the shared source-of-truth reader pipeline. */ +internal sealed interface ReaderRecord { + val readerGen: Long + val residenceRevision: Long + + /** The reader observed [envelope] and installed it at [residenceRevision]. */ + class Row( + val envelope: ValueEnvelope, + override val readerGen: Long, + override val residenceRevision: Long, + /** Successful-write sequence observed before this adapter event entered conflation. */ + val successfulWriteSequenceAtObservation: Long = 0L, + /** Exact consume-once tag consumed while mapping this notification, when any. */ + val consumedAttribution: AttributionTag? = null, + /** Exact commit whose SoT write was active when this adapter event was observed. */ + val activeWriteAttributionAtObservation: AttributionTag? = null, + /** Monotone source-order token captured before adapter-event conflation. */ + val rawObservationSequence: Long = 0L, + ) : ReaderRecord + + /** The reader observed absence and installed it at [residenceRevision]. */ + class Absent( + override val readerGen: Long, + override val residenceRevision: Long, + /** Successful-write sequence observed before this adapter event entered conflation. */ + val successfulWriteSequenceAtObservation: Long = 0L, + /** Exact consume-once tag consumed while mapping this notification, when any. */ + val consumedAttribution: AttributionTag? = null, + /** Exact commit whose SoT write was active when this adapter event was observed. */ + val activeWriteAttributionAtObservation: AttributionTag? = null, + /** Monotone source-order token captured before adapter-event conflation. */ + val rawObservationSequence: Long = 0L, + ) : ReaderRecord + + /** The reader failed without changing residence. */ + class Failure( + val exception: StoreException, + override val readerGen: Long, + override val residenceRevision: Long, + ) : ReaderRecord +} + +/** Adapter-terminal event; engine mapping happens downstream so its failures are never retried. */ +internal sealed interface RawReaderEvent { + class Row( + val value: V?, + /** Reader generation whose upstream adapter produced this observation. */ + val readerGen: Long, + /** Monotone source-order token assigned before conflation. */ + val rawObservationSequence: Long, + /** Exact consume-once tag that was current when this adapter event was observed. */ + val attributionAtObservation: AttributionTag?, + /** Successful-write sequence current when this adapter event was observed. */ + val successfulWriteSequenceAtObservation: Long, + /** Exact commit whose SoT write was active when this adapter event was observed. */ + val activeWriteAttributionAtObservation: AttributionTag?, + /** True when this nonmatching event followed a matching row from that active write. */ + val followedMatchingActiveWriteRow: Boolean, + /** True while a pre-return notification is fenced behind its committed writer-current. */ + val pendingCommitFenceAtObservation: Boolean, + ) : RawReaderEvent + + class Failure( + val exception: StoreException, + ) : RawReaderEvent +} + +/** + * Reconciles a queued reader record with live residence under the caller's state lock. + * + * A record is only a notification. Live residence remains authoritative when delivery is delayed. + */ +internal fun resolveCurrentRecord( + record: ReaderRecord, + currentReaderGen: Long, + currentResidence: ValueEnvelope?, + currentResidenceRevision: Long, +): ReaderRecord? { + if (record.readerGen != currentReaderGen) return null + return when (record) { + is ReaderRecord.Row -> { + val live = currentResidence ?: return null + if (live.value != record.envelope.value) return null + ReaderRecord.Row(live, currentReaderGen, currentResidenceRevision) + } + + is ReaderRecord.Absent -> { + if (currentResidence != null) return null + ReaderRecord.Absent(currentReaderGen, currentResidenceRevision) + } + + is ReaderRecord.Failure -> + ReaderRecord.Failure(record.exception, currentReaderGen, currentResidenceRevision) + } +} + +/** True only when a post-suspension row is the exact residence observation that was reserved. */ +internal fun isSameResolvedRow( + reserved: ReaderRecord.Row, + current: ReaderRecord.Row, +): Boolean = + current.readerGen == reserved.readerGen && + current.residenceRevision == reserved.residenceRevision && + current.envelope.value == reserved.envelope.value diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/RealStore.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/RealStore.kt new file mode 100644 index 000000000..69ed589eb --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/RealStore.kt @@ -0,0 +1,218 @@ +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreException +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.core.seam.Fetcher +import org.mobilenativefoundation.store6.core.seam.FreshnessValidator +import org.mobilenativefoundation.store6.core.seam.KeyEvents +import org.mobilenativefoundation.store6.core.seam.Overlay +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import org.mobilenativefoundation.store6.core.seam.StoreRuntime +import org.mobilenativefoundation.store6.core.seam.StoreTelemetry +import org.mobilenativefoundation.store6.core.seam.WallClock + +/** + * Store implementation backed by one supervised [KeyEngine] per canonical key. + * + * Each engine receives its own supervised child scope. Closing the store cancels the parent job + * and all active engine work without allowing one key's fetch failure to cancel another key. + * Freshness policies are honored per the [Freshness] contract; planning is delegated to the + * engine's validator. + */ +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +internal class RealStore( + fetcher: Fetcher, + private val sot: SourceOfTruth, + wallClock: WallClock, + private val bookkeeper: Bookkeeper, + validator: FreshnessValidator, + internal val telemetry: StoreTelemetry?, + private val overlay: Overlay?, + private val maxIdleKeys: Int, +) : Store { + private val storeJob = SupervisorJob() + private val storeScope = CoroutineScope(Dispatchers.Default + storeJob) + private val maintenanceCoordinator = MaintenanceCoordinator() + internal val events = + MutableSharedFlow( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + internal val runtime: StoreRuntime = RealStoreRuntime(this) + private val registry = + KeyRegistry(maxIdleKeys) { key, id, hooks -> + val engineJob = SupervisorJob(storeJob) + KeyEngine( + key = key, + keyId = id, + fetcher = fetcher, + sot = sot, + bookkeeper = bookkeeper, + validator = validator, + wallClock = wallClock, + telemetry = telemetry, + overlay = overlay, + events = events, + engineScope = CoroutineScope(storeScope.coroutineContext + engineJob), + residencyHooks = hooks, + maintenanceCoordinator = maintenanceCoordinator, + ) + } + + init { + storeJob.invokeOnCompletion { registry.clearOnClose() } + } + + override fun stream( + key: K, + freshness: Freshness, + ): Flow> { + ensureOpen() + return flow { + ensureOpen() + registry.withEngine(key) { engine -> + emitAll(engine.stream(freshness)) + } + } + } + + override suspend fun get( + key: K, + freshness: Freshness, + ): V { + return withEngine(key) { engine -> engine.get(freshness) } + } + + override suspend fun invalidate(key: K) { + ensureOpen() + registry.withEngine(key) { engine -> engine.invalidate() } + } + + override suspend fun invalidateNamespace(namespace: StoreNamespace) { + ensureOpen() + durably("invalidateNamespace", "namespace '${namespace.value}'") { + bookkeeper.advanceStaleWatermark(namespace) + } + registry.forEachResident(namespace.value) { engine -> engine.invalidateResident() } + } + + override suspend fun invalidateAll() { + ensureOpen() + durably("invalidateAll", "all namespaces") { + bookkeeper.advanceGlobalStaleWatermark() + } + registry.forEachResident(namespace = null) { engine -> engine.invalidateResident() } + } + + override suspend fun clear(key: K) { + ensureOpen() + registry.withEngine(key) { engine -> engine.clear() } + } + + override suspend fun clearNamespace(namespace: StoreNamespace) { + ensureOpen() + val cleared = + maintenanceCoordinator.withNamespaceMaintenance(namespace.value) { + val firstSweep = + registry.snapshotAndForEachResident(namespace.value) { engine -> + engine.clearResident() + } + durably("clearNamespace", "namespace '${namespace.value}'") { + sot.deleteNamespace(namespace) + } + durably("clearNamespace", "namespace '${namespace.value}'") { + bookkeeper.forgetNamespace(namespace) + } + registry.forEachResident(namespace.value) { engine -> engine.clearResident() } + firstSweep + } + cleared.forEach { engine -> engine.notifyBulkClearCompleted() } + } + + override suspend fun clearAll() { + ensureOpen() + val cleared = + maintenanceCoordinator.withGlobalMaintenance { + val firstSweep = + registry.snapshotAndForEachResident(namespace = null) { engine -> + engine.clearResident() + } + durably("clearAll", "all namespaces") { sot.deleteAll() } + durably("clearAll", "all namespaces") { bookkeeper.forgetAll() } + registry.forEachResident(namespace = null) { engine -> engine.clearResident() } + firstSweep + } + cleared.forEach { engine -> engine.notifyBulkClearCompleted() } + } + + internal suspend fun withEngine( + key: K, + action: suspend (KeyEngine) -> R, + ): R { + ensureOpen() + return registry.withEngine(key, action) + } + + internal suspend fun residentEngineCountForTest(): Int = registry.residentCountForTest() + + internal suspend fun idleEngineCountForTest(): Int = registry.idleCountForTest() + + internal suspend fun createdEngineCountForTest(): Long = registry.createdCountForTest() + + internal suspend fun destroyedEngineCountForTest(): Long = registry.destroyedCountForTest() + + internal suspend fun awaitTerminationForTest() = storeJob.join() + + override fun close() { + storeJob.cancel(CancellationException(STORE_CLOSED_MESSAGE)) + registry.clearOnClose() + } + + /** Fails deterministically when an operation starts after [close]. */ + private fun ensureOpen() { + if (!storeJob.isActive) { + throw storeClosedException() + } + } + + /** Runs one durable maintenance step, preserving cancellation and typing other failures. */ + private suspend fun durably( + operation: String, + scope: String, + step: suspend () -> Unit, + ) { + try { + step() + } catch (cancellation: CancellationException) { + throw cancellation + } catch (failure: Throwable) { + val message = + "$operation failed for $scope: ${failure.message}. Durable maintenance runs in " + + "a fixed order (stale mark before signal, delete before forget); completed " + + "steps remain applied and are conservative. Retry the operation and inspect " + + "the cause for the underlying persistence failure." + throw StoreException( + error = StoreError.Persistence(message = message, cause = failure), + cause = failure, + ) + } + } +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/RealStoreRuntime.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/RealStoreRuntime.kt new file mode 100644 index 000000000..ab1388306 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/RealStoreRuntime.kt @@ -0,0 +1,38 @@ +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.coroutines.flow.Flow +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.seam.KeyEvents +import org.mobilenativefoundation.store6.core.seam.StoreRuntime +import org.mobilenativefoundation.store6.core.seam.StoreTelemetry +import org.mobilenativefoundation.store6.core.seam.StoreWriteHandle + +/** The engine-backed capability handle; obtained only through the seam `runtime()` accessor. */ +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +internal class RealStoreRuntime( + private val store: RealStore, +) : StoreRuntime, StoreWriteHandle { + override val writeHandle: StoreWriteHandle + get() = this + + override val keyEvents: Flow + get() = store.events + + override val telemetry: StoreTelemetry? + get() = store.telemetry + + override suspend fun apply( + key: K, + value: V, + ) = store.withEngine(key) { engine -> engine.applyWrite(value) } + + /** Routes through KeyEngine.invalidate, producing Invalidated events and telemetry. */ + override suspend fun markStale(key: K) = store.invalidate(key) + + override suspend fun confirmFresh( + key: K, + etag: String?, + ) = store.withEngine(key) { engine -> engine.confirmFresh(etag) } +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/StoreLifecycle.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/StoreLifecycle.kt new file mode 100644 index 000000000..564da0f22 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/StoreLifecycle.kt @@ -0,0 +1,26 @@ +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.coroutines.CancellationException + +/** Stable diagnostic used when an operation is attempted after store closure. */ +internal const val STORE_CLOSED_MESSAGE: String = "Store is closed." + +/** Diagnostic for the internal cancellation that destroys an evicted quiescent engine. */ +internal const val ENGINE_EVICTED_MESSAGE: String = "Engine evicted after quiescence." + +/** Default bound on quiescent engine residency (StoreBuilder.maxIdleKeys). */ +internal const val DEFAULT_MAX_IDLE_ENGINES: Int = 128 + +/** Grace period the shared reader pipeline stays subscribed after its last collector leaves. */ +internal const val READER_PIPELINE_GRACE_MILLIS: Long = 100L + +/** Fixed defensive delay before retrying a failed reader subscription. */ +internal const val READER_RETRY_DELAY_MILLIS: Long = 100L + +/** Creates the deterministic failure for an operation that requires an open store. */ +internal fun storeClosedException(): IllegalStateException = + IllegalStateException(STORE_CLOSED_MESSAGE) + +/** Creates the cancellation used to terminate work that was active when the store closed. */ +internal fun storeClosedCancellation(): CancellationException = + CancellationException(STORE_CLOSED_MESSAGE) diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/StoreResultFlows.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/StoreResultFlows.kt new file mode 100644 index 000000000..87ed6f500 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/StoreResultFlows.kt @@ -0,0 +1,104 @@ +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.yield +import org.mobilenativefoundation.store6.core.StoreResult + +/** + * Decouples Store result production from collection with a kind-bounded pending queue. + * + * The queue holds at most one pending element per [StoreResult] kind (≤ 4). The latest occurrence + * wins for each kind, and delivery order is the relative order of those latest occurrences. + * When pending results drain before the next same-kind emission, every emission is delivered. A + * blocked collector instead receives at least the latest pending result per kind. This realizes + * an O(1)-per-collector bound that covers lifecycle signals as well as data. + * + * A pathological fetch-error storm cannot grow a collector's buffer because the queue is + * kind-bounded. This operator bounds delivery buffering only and adds or changes no engine retry + * or backoff behavior. [StoreResult.Revalidated] is never conflated away in favor of another kind: + * only a newer Revalidated supersedes an older queued one for a blocked collector, so the kind is + * never lost. + */ +internal fun Flow>.conflateLatestData(): Flow> = flow { + var terminalFailure: Throwable? = null + + coroutineScope { + val pending = ArrayDeque>() + val mutex = Mutex() + val wakeVersion = MutableStateFlow(0L) + var upstreamComplete = false + var upstreamFailure: Throwable? = null + + val upstream = launch { + try { + this@conflateLatestData.collect { result -> + mutex.withLock { + pending.removeAll { queued -> sameKind(queued, result) } + pending.addLast(result) + wakeVersion.value += 1 + } + yield() + } + } catch (failure: Throwable) { + currentCoroutineContext().ensureActive() + mutex.withLock { upstreamFailure = failure } + } finally { + mutex.withLock { + upstreamComplete = true + wakeVersion.value += 1 + } + } + } + + try { + while (true) { + var next: StoreResult? = null + var complete = false + var failure: Throwable? = null + var observedWakeVersion = 0L + + mutex.withLock { + if (pending.isNotEmpty()) { + next = pending.removeFirst() + } else if (upstreamComplete) { + complete = true + failure = upstreamFailure + } else { + observedWakeVersion = wakeVersion.value + } + } + + when { + next != null -> emit(next!!) + complete -> { + terminalFailure = failure + break + } + else -> wakeVersion.first { it > observedWakeVersion } + } + } + } finally { + upstream.cancel() + } + } + + terminalFailure?.let { throw it } +} + +/** Two results share a kind when a newer one supersedes the older for a blocked collector. */ +private fun sameKind(a: StoreResult<*>, b: StoreResult<*>): Boolean = + when (a) { + is StoreResult.Data<*> -> b is StoreResult.Data<*> + is StoreResult.Loading -> b is StoreResult.Loading + is StoreResult.Revalidated -> b is StoreResult.Revalidated + is StoreResult.Error -> b is StoreResult.Error + } diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/Transitions.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/Transitions.kt new file mode 100644 index 000000000..f3c5d92ce --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/Transitions.kt @@ -0,0 +1,299 @@ +package org.mobilenativefoundation.store6.core.internal + +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreMeta + +/** An input applied to the immutable state for a canonical key. */ +internal sealed interface KeyEvent { + /** Requests a fetch, offering [fresh] as the ticket if no fetch is active. */ + class EnsureFetch( + val fresh: FetchTicket, + ) : KeyEvent + + /** Reports that the fetch represented by [ticket] produced [value] ready to commit. */ + class CommitFetch( + val ticket: FetchTicket, + val value: Any, + val meta: StoreMeta, + ) : KeyEvent + + /** Reports that the fetch represented by [ticket] revalidated the resident value. */ + class CommitRevalidated( + val ticket: FetchTicket, + ) : KeyEvent + + /** + * Reports that the write handle confirmed [value] for direct source-of-truth commit; [ticket] + * is the synthetic owner of the stamped SOT attribution. + */ + class ApplyWrite( + val ticket: FetchTicket, + val value: Any, + val meta: StoreMeta, + ) : KeyEvent + + /** Reports that the fetch represented by [ticket] observed a server-side deletion. */ + class CommitDeleted( + val ticket: FetchTicket, + ) : KeyEvent + + /** Reports that the fetch represented by [ticket] reached a terminal outcome. */ + class SettleFetch( + val ticket: FetchTicket, + ) : KeyEvent + + /** Marks the key stale without removing its value. */ + data object Invalidate : KeyEvent + + /** Destructively removes the key's value and supersedes any in-flight fetch commit. */ + data object Clear : KeyEvent + + /** Returns and clears [observed] only when it is still the current consume-once tag. */ + class ConsumeAttribution( + val observed: AttributionTag?, + ) : KeyEvent + + /** Revokes the current consume-once attribution tag. */ + data object RevokeAttribution : KeyEvent +} + +/** A side effect for the engine to interpret after a pure state transition. */ +internal sealed interface KeyEffect { + /** Launch a fetch owned by [ticket]. */ + class Launch( + val ticket: FetchTicket, + ) : KeyEffect + + /** Wait for the existing fetch represented by [ticket]. */ + class Join( + val ticket: FetchTicket, + ) : KeyEffect + + /** Accept the guarded fetch and stamp attribution; the engine persists, then converges it. */ + data object Commit : KeyEffect + + /** Refresh resident metadata inside the same critical section. */ + data object CommitRevalidation : KeyEffect + + /** Stamp SOT attribution; the engine writes the SoT and residence through under writeLock. */ + data object CommitWrite : KeyEffect + + /** Null out residence and forget bookkeeping: the server deleted the value. */ + data object CommitDelete : KeyEffect + + /** The fetch result was rejected because a clear advanced the clear epoch after launch. */ + data object Superseded : KeyEffect + + /** The key was marked stale; no residence change is required. */ + data object Invalidated : KeyEffect + + /** Null out residence inside the same critical section. */ + data object ClearResidence : KeyEffect + + /** The matching active fetch was settled. */ + data object Settled : KeyEffect + + /** The consume-once attribution returned by a consume event, if one was present. */ + class Consumed( + val tag: AttributionTag?, + ) : KeyEffect + + /** The consume-once attribution was revoked. */ + data object AttributionRevoked : KeyEffect + + /** The event did not apply to the current state. */ + data object Ignored : KeyEffect +} + +/** The immutable state and engine effect produced by applying one [KeyEvent]. */ +internal data class KeyTransition( + val state: KeyState, + val effect: KeyEffect, +) + +/** + * Applies [event] to [state] without performing suspension, I/O, or coroutine work. + * + * Ticket comparisons are identity checks so an older fetch can never commit into or settle a + * newer fetch's slot. A successful commit settles the slot before its ordered source-of-truth + * tail; another demand may reserve the next ticket, while the engine's write lock serializes both + * mutations and the settled ticket's disposition preserves exact attribution until its outcome is + * published. Epoch fields are monotone and are never reset by any event. + */ +internal fun transition( + state: KeyState, + event: KeyEvent, +): KeyTransition = + when (event) { + is KeyEvent.EnsureFetch -> + when (val slot = state.fetch) { + FetchSlot.Idle -> + KeyTransition( + state = state.copy( + fetch = FetchSlot.InFlight( + ticket = event.fresh, + clearEpochAtLaunch = state.clearEpoch, + ), + ), + effect = KeyEffect.Launch(event.fresh), + ) + + is FetchSlot.InFlight -> + KeyTransition(state = state, effect = KeyEffect.Join(slot.ticket)) + } + + is KeyEvent.CommitFetch -> + when (val slot = state.fetch) { + is FetchSlot.InFlight -> + when { + slot.ticket !== event.ticket -> + KeyTransition(state = state, effect = KeyEffect.Ignored) + + slot.clearEpochAtLaunch != state.clearEpoch -> + KeyTransition(state = state, effect = KeyEffect.Superseded) + + else -> + KeyTransition( + state = state.copy( + fetch = FetchSlot.Idle, + attribution = AttributionTag( + owner = event.ticket, + value = event.value, + origin = Origin.FETCHER, + meta = event.meta, + staleEpochAtCommit = state.staleEpoch, + ), + ), + effect = KeyEffect.Commit, + ) + } + + FetchSlot.Idle -> + KeyTransition(state = state, effect = KeyEffect.Ignored) + } + + is KeyEvent.CommitRevalidated -> + when (val slot = state.fetch) { + is FetchSlot.InFlight -> + when { + slot.ticket !== event.ticket -> + KeyTransition(state = state, effect = KeyEffect.Ignored) + + slot.clearEpochAtLaunch != state.clearEpoch -> + KeyTransition(state = state, effect = KeyEffect.Superseded) + + else -> + KeyTransition( + state = state.copy( + fetch = FetchSlot.Idle, + attribution = null, + ), + effect = KeyEffect.CommitRevalidation, + ) + } + + FetchSlot.Idle -> + KeyTransition(state = state, effect = KeyEffect.Ignored) + } + + is KeyEvent.ApplyWrite -> + KeyTransition( + state = state.copy( + attribution = AttributionTag( + owner = event.ticket, + value = event.value, + origin = Origin.SOT, + meta = event.meta, + staleEpochAtCommit = state.staleEpoch, + ), + ), + effect = KeyEffect.CommitWrite, + ) + + is KeyEvent.CommitDeleted -> + when (val slot = state.fetch) { + is FetchSlot.InFlight -> + when { + slot.ticket !== event.ticket -> + KeyTransition(state = state, effect = KeyEffect.Ignored) + + slot.clearEpochAtLaunch != state.clearEpoch -> + KeyTransition(state = state, effect = KeyEffect.Superseded) + + else -> + KeyTransition( + // staleEpoch deliberately unchanged: a deleted key is absent-and- + // satisfied, so streams are not driven into a refetch loop; the + // next demand fetches. clearEpoch advances because the removal is + // destructive. + state = state.copy( + fetch = FetchSlot.Idle, + clearEpoch = state.clearEpoch + 1, + readerGen = state.readerGen + 1, + attribution = null, + ), + effect = KeyEffect.CommitDelete, + ) + } + + FetchSlot.Idle -> + KeyTransition(state = state, effect = KeyEffect.Ignored) + } + + is KeyEvent.SettleFetch -> + when (val slot = state.fetch) { + is FetchSlot.InFlight -> + if (slot.ticket === event.ticket) { + KeyTransition( + state = state.copy(fetch = FetchSlot.Idle), + effect = + if (slot.clearEpochAtLaunch != state.clearEpoch) { + KeyEffect.Superseded + } else { + KeyEffect.Settled + }, + ) + } else { + KeyTransition(state = state, effect = KeyEffect.Ignored) + } + + FetchSlot.Idle -> + KeyTransition(state = state, effect = KeyEffect.Ignored) + } + + KeyEvent.Invalidate -> + KeyTransition( + state = state.copy(staleEpoch = state.staleEpoch + 1), + effect = KeyEffect.Invalidated, + ) + + KeyEvent.Clear -> + KeyTransition( + state = state.copy( + clearEpoch = state.clearEpoch + 1, + staleEpoch = state.staleEpoch + 1, + readerGen = state.readerGen + 1, + attribution = null, + ), + effect = KeyEffect.ClearResidence, + ) + + is KeyEvent.ConsumeAttribution -> { + val tag = state.attribution.takeIf { it === event.observed } + KeyTransition( + state = if (tag == null) state else state.copy(attribution = null), + effect = KeyEffect.Consumed(tag), + ) + } + + KeyEvent.RevokeAttribution -> + KeyTransition( + state = + if (state.attribution == null) { + state + } else { + state.copy(attribution = null) + }, + effect = KeyEffect.AttributionRevoked, + ) + } diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/ValueEnvelope.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/ValueEnvelope.kt new file mode 100644 index 000000000..e29765bae --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/ValueEnvelope.kt @@ -0,0 +1,24 @@ +package org.mobilenativefoundation.store6.core.internal + +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreMeta + +/** An immutable resident value paired with the provenance needed for honest emissions. */ +internal data class ValueEnvelope( + val value: V, + val origin: Origin, + + /** + * Freshness metadata recorded when this value was committed, or `null` when provenance is + * unknown (an external source-of-truth row or a hydrated pre-existing row). Null meta is a + * conservative posture: the value reports `isStale = true`, age zero, and never + * satisfies demand without a revalidation (see the null-meta planning rule). + */ + val meta: StoreMeta?, + + /** The key's stale epoch stamped when this envelope was produced. */ + val staleEpochAtCommit: Long, + + /** Ticket whose no-row 304 direct-send path exclusively owns this exact envelope identity. */ + val directRevalidationOwner: FetchTicket? = null, +) diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.kt new file mode 100644 index 000000000..3d55af8ef --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.kt @@ -0,0 +1,15 @@ +package org.mobilenativefoundation.store6.core.internal + +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.seam.WallClock + +/** The production clock backed by each platform's system clock. */ +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +internal val SystemWallClock: WallClock = + object : WallClock { + override fun nowEpochMillis(): Long = currentEpochMillis() + } + +/** Reads the platform's wall clock in milliseconds since the Unix epoch. */ +internal expect fun currentEpochMillis(): Long diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Bookkeeper.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Bookkeeper.kt new file mode 100644 index 000000000..f24cc4c41 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Bookkeeper.kt @@ -0,0 +1,119 @@ +package org.mobilenativefoundation.store6.core.seam + +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreMeta +import org.mobilenativefoundation.store6.core.StoreNamespace + +/** + * Tracks successful metadata, consecutive failures, and durable staleness for canonical keys. + * + * Every per-key operation derives identity exclusively from + * `(key.namespace.value, key.canonicalId())`; object identity and concrete key class are never part + * of bookkeeping identity. Every namespace operation similarly normalizes identity exclusively by + * `namespace.value`; [StoreNamespace] object identity is never part of namespace matching. + * Implementations use one store-local monotone sequence shared by every success, per-key stale + * mark, namespace watermark, and global watermark. A key is durably stale exactly when + * `max(mark/ns/global) > (success ?: 0)`. Therefore a failure-only record is not durably stale until + * covered by a positive mark or watermark, and a later success clears earlier staleness. + * + * [recordSuccess], [recordFailure], and operational per-key [forget] are operationally infallible: + * implementations absorb or report their own storage failures and do not throw them through this + * interface. Cooperative cancellation may still propagate. [recordSuccess] clears the prior + * failure timestamp and count. + * + * The maintenance methods [markStale], [advanceStaleWatermark], + * [advanceGlobalStaleWatermark], [forgetNamespace], and [forgetAll] may report storage failures by + * throwing. Each is exception-atomic for every [Throwable], including cancellation: normal return + * means the full operation was applied, while throwing means it had no effect. Forget operations + * remove key records but never reset namespace or global watermarks, and watermarks otherwise only + * advance. + */ +@ExperimentalStoreApi +@SubclassOptInRequired(DelicateStoreApi::class) +public interface Bookkeeper { + /** + * Records successful metadata for [key], assigns the next shared store-local monotone sequence, + * and clears its failure timestamp and count. + */ + public suspend fun recordSuccess( + key: StoreKey, + meta: StoreMeta, + ) + + /** + * Records one consecutive failure for [key] at [atEpochMillis] without making a failure-only + * record durably stale. + */ + public suspend fun recordFailure( + key: StoreKey, + atEpochMillis: Long, + ) + + /** + * Returns canonical status for [key], including watermark-only staleness, or null when neither a + * record nor a covering watermark exists. + */ + public suspend fun status(key: StoreKey): KeyStatus? + + /** + * Forgets [key]'s record, including its per-key stale mark, without resetting namespace or + * global watermarks. + */ + public suspend fun forget(key: StoreKey) + + /** + * Assigns the next shared sequence to [key]'s stale mark as one exception-atomic, fallible + * maintenance operation; the shared sequence never resets. + */ + public suspend fun markStale(key: StoreKey) + + /** + * Assigns the next shared sequence to durable stale coverage for every key in [namespace] as + * one exception-atomic, fallible maintenance operation; the watermark never resets. + */ + public suspend fun advanceStaleWatermark(namespace: StoreNamespace) + + /** + * Assigns the next shared sequence to global stale coverage as one exception-atomic, fallible + * maintenance operation; the watermark never resets. + */ + public suspend fun advanceGlobalStaleWatermark() + + /** + * Forgets key records in [namespace] without resetting watermarks as one exception-atomic, + * fallible maintenance operation. + */ + public suspend fun forgetNamespace(namespace: StoreNamespace) + + /** + * Forgets all key records without resetting watermarks as one exception-atomic, fallible + * maintenance operation. + */ + public suspend fun forgetAll() +} + +/** + * Immutable bookkeeping state for one canonical key. + * + * `durablyStale` reflects the exact watermark algebra `max(mark/ns/global) > (success ?: 0)`. + * A failure-only record therefore reports false until a mark or watermark covers it. + */ +@ExperimentalStoreApi +public class KeyStatus( + /** Metadata from the latest recorded success, or null when none has been recorded. */ + public val meta: StoreMeta?, + + /** The shared monotone sequence assigned to the latest success, or null when none exists. */ + public val lastSuccessSequence: Long?, + + /** The time of the latest [Bookkeeper.recordFailure], null once a success clears it. */ + public val lastFailureAtEpochMillis: Long?, + + /** Failures recorded since the latest success; zero once a success clears the streak. */ + public val consecutiveFailures: Int, + + /** Whether a key, namespace, or global stale sequence outranks this key's latest success. */ + public val durablyStale: Boolean, +) diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Fetcher.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Fetcher.kt new file mode 100644 index 000000000..c04e9191b --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Fetcher.kt @@ -0,0 +1,28 @@ +package org.mobilenativefoundation.store6.core.seam + +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey + +/** + * Retrieves remote values for a [StoreKey] without mutating Store state. + * + * Implementations must be cooperative with coroutine cancellation and must not write Store + * residence, persistence, or bookkeeping directly. The engine supplies a non-null `etag` if and + * only if it selected [FetchPlan.Conditional]; return [FetcherResult.NotModified] to confirm that + * the resident value is still current. + * + * @param K the key type accepted by the fetcher + * @param V the non-null value type produced by the fetcher + */ +@ExperimentalStoreApi +@SubclassOptInRequired(DelicateStoreApi::class) +public interface Fetcher { + /** Retrieves [key], conditionally against [etag] when one is supplied by the engine. */ + // docs:snippet:guides-fetchers-seam-signature + public suspend fun fetch( + key: K, + etag: String?, + ): FetcherResult + // docs:snippet:end +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FetcherResult.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FetcherResult.kt new file mode 100644 index 000000000..6a3fbd915 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FetcherResult.kt @@ -0,0 +1,45 @@ +package org.mobilenativefoundation.store6.core.seam + +import org.mobilenativefoundation.store6.core.StoreBuilder +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreResult + +/** + * The result vocabulary for a fetcher registered with [StoreBuilder.fetcherOfResult] or the seam + * [Fetcher] overload. + * + * A plain [StoreBuilder.fetcher] is success-or-throw sugar: a returned value becomes [Success], + * while a thrown exception follows the store's fetch-failure path. [NotModified] refreshes the + * resident value's metadata and emits [StoreResult.Revalidated]; without a resident value it + * produces [StoreError.Missing]. A seam [Fetcher] receives the ETag selected by a conditional + * plan; the lambda sugar ignores conditional ETags because its signatures do not accept them. + * [Error] is equivalent to throwing [Error.cause] from the fetcher. [Deleted] destructively clears + * the resident value and forgets its freshness; streams and waiters receive [StoreError.Missing], + * and the deletion does not trigger an automatic refetch. + * + * @param V the non-null value type produced by the fetcher + */ +public sealed interface FetcherResult { + /** A fetched `value`, optionally identified by `etag`. */ + public class Success( + public val value: V, + public val etag: String? = null, + ) : FetcherResult + + /** + * The resident value is unchanged and its metadata should be refreshed with `etag`. + * + * @property etag the refreshed ETag, or null to keep the previously recorded tag + */ + public class NotModified( + public val etag: String? = null, + ) : FetcherResult + + /** A fetch failure equivalent to throwing `cause` from the fetcher. */ + public class Error( + public val cause: Throwable, + ) : FetcherResult + + /** A destructive remote deletion that clears the resident value without auto-refetching. */ + public data object Deleted : FetcherResult +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FreshnessValidator.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FreshnessValidator.kt new file mode 100644 index 000000000..f3672aaf3 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FreshnessValidator.kt @@ -0,0 +1,63 @@ +package org.mobilenativefoundation.store6.core.seam + +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreMeta + +/** + * The value and bookkeeping facts used to plan a read. + * + * `status` carries the durable bookkeeping posture captured before the corresponding engine-state + * snapshot. A resident value with null `meta` is treated as conservatively stale. + */ +@ExperimentalStoreApi +public class FreshnessContext( + /** Whether an in-memory resident value existed in the snapshot this plan is made from. */ + public val hasResidentValue: Boolean, + + /** The resident value's recorded freshness metadata, or null when it has none or is absent. */ + public val meta: StoreMeta?, + + /** Whether the resident value was committed before the stale epoch this plan runs against. */ + public val epochStale: Boolean, + + /** The freshness policy of the read being planned. */ + public val freshness: Freshness, + + /** The wall-clock reading captured for this plan, in Unix epoch milliseconds. */ + public val nowEpochMillis: Long, + + /** The durable bookkeeping posture captured before the corresponding engine-state snapshot. */ + public val status: KeyStatus? = null, +) + +/** Selects the fetch plan for one coherent [FreshnessContext]. */ +@ExperimentalStoreApi +@SubclassOptInRequired(DelicateStoreApi::class) +public interface FreshnessValidator { + /** Plans whether and how the current read should fetch as a pure function of [context]. */ + public fun plan(context: FreshnessContext): FetchPlan +} + +/** Fetch action selected by a [FreshnessValidator]. */ +@ExperimentalStoreApi +public sealed interface FetchPlan { + /** + * Skips fetching. Skip with no resident value yields [StoreError.Missing] (get throws, stream + * emits Error). + */ + public data object Skip : FetchPlan + + /** Performs an unconditional fetch and optionally serves the resident value while it runs. */ + public class Fetch( + public val servesResidentWhileFetching: Boolean, + ) : FetchPlan + + /** Performs a conditional fetch for `etag` and optionally serves residence while it runs. */ + public class Conditional( + public val etag: String, + public val servesResidentWhileFetching: Boolean, + ) : FetchPlan +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/KeyEvents.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/KeyEvents.kt new file mode 100644 index 000000000..fb901843f --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/KeyEvents.kt @@ -0,0 +1,46 @@ +package org.mobilenativefoundation.store6.core.seam + +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreKey + +/** + * Advisory notifications produced at Store engine writer points. + * + * This hierarchy is deliberately open rather than sealed, so consumers must retain an `else` + * branch and future minor-version variants remain source- and binary-compatible. Constructors are + * internal because only the engine produces events. + * + * Delivery is a best-effort hot stream with replay `0` and a bounded buffer of `64` that drops the + * oldest event on overflow. Correctness must never depend on observing every event; durable facts + * remain in engine state and bookkeeping. The flow never completes, including after `Store.close`; + * collectors must scope collection to their own lifecycle. + * + * [Written] is produced after a fetch commit with [Origin.FETCHER] and after write-handle apply with + * [Origin.SOT]. [Invalidated] is produced for per-key invalidation and for each swept resident in + * namespace or global invalidation. [Deleted] is produced for per-key clear, a committed server + * deletion, and once per engine in the authoritative first sweep of namespace or global clear. + * Purge sweeps, nonresident watermark coverage, external source-of-truth changes, and superseded + * fetches produce no event. + */ +@ExperimentalStoreApi +public abstract class KeyEvents internal constructor() { + /** Key whose engine produced this advisory event. */ + public abstract val key: StoreKey + + /** Reports a successful writer commit with its installed `origin`. */ + public class Written internal constructor( + override val key: StoreKey, + public val origin: Origin, + ) : KeyEvents() + + /** Reports a successful stale mark for `key`. */ + public class Invalidated internal constructor( + override val key: StoreKey, + ) : KeyEvents() + + /** Reports a successful destructive removal for `key`. */ + public class Deleted internal constructor( + override val key: StoreKey, + ) : KeyEvents() +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Overlay.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Overlay.kt new file mode 100644 index 000000000..a1cdc2f12 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Overlay.kt @@ -0,0 +1,52 @@ +package org.mobilenativefoundation.store6.core.seam + +import kotlinx.coroutines.flow.Flow +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey + +/** + * Projects confirmed stream residence through one engine-owned writer per key. + * + * [apply] receives the latest confirmed value, or `null` for confirmed absence. Returning that + * same value by equality preserves its envelope, origin, age, staleness, and refresh state. + * Returning a different non-null value emits it from the overlay with zero age and without + * staleness; its `refreshing` flag always reflects the live fetch slot. Returning `null` exposes + * the normal absent/loading transition. Consequently a + * non-null result over a null base is an optimistic create, while a null result over a non-null + * base is an optimistic delete. `Store.get` is intentionally not projected. + * + * | Confirmed base | [apply] result | Stream projection | + * |---|---|---| + * | non-null | equal value | pass through the confirmed envelope and its metadata | + * | non-null | different non-null value | overlay data | + * | non-null | `null` | absence (optimistic delete) | + * | `null` | non-null value | overlay data (optimistic create) | + * | `null` | `null` | confirmed absence | + * + * The engine invokes [apply] exactly once for each residence revision or matching [changes] + * emission actually accepted by the key's single writer, independent of collector count. [apply] + * must be pure, non-blocking, and no-throw, and must not call back into the Store. It runs outside + * Store locks. [changes] is filtered by canonical key identity; it may complete normally, but it + * must not fail. The mutations extension is the intended producer of change signals after its + * confirmed-commit-then-retire ordering. + * + * A defensive violation by [apply] or [changes], including a self-originated cancellation while + * the engine remains active, terminalizes projection for that key. Every current or future + * projected stream then fails with a deterministic internal exception retaining the cause; the + * engine never silently falls back to an unprojected value. + */ +// docs:snippet:guides-extending-overlay-seam +@ExperimentalStoreApi +@SubclassOptInRequired(DelicateStoreApi::class) +public interface Overlay { + /** Computes the current projected value for [key] from confirmed [base] or absence. */ + public fun apply( + key: K, + base: V?, + ): V? + + /** Signals keys whose projection inputs changed without changing confirmed residence. */ + public val changes: Flow +} +// docs:snippet:end diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/SourceOfTruth.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/SourceOfTruth.kt new file mode 100644 index 000000000..0c34e9bd5 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/SourceOfTruth.kt @@ -0,0 +1,76 @@ +package org.mobilenativefoundation.store6.core.seam + +import kotlinx.coroutines.flow.Flow +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace + +/** + * Persistence seam for the current nullable row associated with a [StoreKey]. + * + * Implementations must uphold all of the following reader-liveness and mutation semantics: + * + * - Every collection of [reader] immediately first emits the current row, or `null` when absent. + * - An active collection emits every subsequent change made through this instance, including a + * write equal to the current value and any matching [delete], [deleteNamespace], or [deleteAll] + * as `null`. Emissions may be conflated. + * - A [reader] collection never completes normally. A non-cancellation collection failure is + * permitted; the engine retries it, and each new attempt again starts with the current row. + * Collection cancellation propagates. + * - On normal return, [write], [delete], [deleteNamespace], and [deleteAll] provide + * read-your-writes: a subsequent [reader] collection starts with the applied row or absence, and + * each mutation's current-row notification has been published to every matching active + * collection. Those notifications may still be queued in downstream operators. A mutation may + * publish intermediate rows (including `null`), but the notification of each applied row or + * absence must be that mutation's final notification for that row before normal return. + * Therefore a notification that supersedes a successfully returned mutation is ordered after + * the return boundary. + * - Mutation completion is exception-atomic for every [Throwable], including + * `CancellationException`: normal return means the mutation was applied, while throwing means + * it was not applied. + * + * Reactivity to changes made through another source-of-truth instance is implementation-specific. + * External changes made while no [reader] is collected must appear in the next collection's first + * emission; the engine's memory fast path may serve the previously observed value until that + * collection begins. + * + * `clearCache` is deliberately absent because cache clearing is not a persistence mutation. + * + * @param K the key type used to locate a row + * @param V the non-null row type + */ +@ExperimentalStoreApi +@SubclassOptInRequired(DelicateStoreApi::class) +public interface SourceOfTruth { + /** Returns the live row stream for [key] under the contract documented on this interface. */ + public fun reader(key: K): Flow + + /** Persists [value] for [key] under the mutation contract documented on this interface. */ + public suspend fun write( + key: K, + value: V, + ) + + /** Destructively removes the row for [key] under the documented mutation contract. */ + public suspend fun delete(key: K) + + /** + * Destructively removes every existing row in [namespace]. + * + * Matching active [reader] collections receive `null` and remain live for later writes. On + * normal return, all matching rows have been removed and their `null` notifications published. + * Cancellation and all other failures preserve exception atomicity under the interface + * mutation contract. + */ + public suspend fun deleteNamespace(namespace: StoreNamespace) + + /** + * Destructively removes every existing row in every namespace. + * + * All active [reader] collections receive `null` and remain live for later writes. On normal + * return, all rows have been removed and their `null` notifications published. Cancellation + * and all other failures preserve exception atomicity under the interface mutation contract. + */ + public suspend fun deleteAll() +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreResults.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreResults.kt new file mode 100644 index 000000000..2709e1508 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreResults.kt @@ -0,0 +1,28 @@ +package org.mobilenativefoundation.store6.core.seam + +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreException +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreMeta +import org.mobilenativefoundation.store6.core.StoreResult +import kotlin.time.Duration + +/** + * StoreResults is the sanctioned construction door for extensions, fakes, and tests; internal constructors remain internal. + */ +@ExperimentalStoreApi +public object StoreResults { + public fun loading(): StoreResult.Loading = StoreResult.Loading() + public fun data(value: V, origin: Origin, age: Duration, isStale: Boolean, refreshing: Boolean): StoreResult.Data = StoreResult.Data(value, origin, age, isStale, refreshing) + public fun revalidated(age: Duration): StoreResult.Revalidated = StoreResult.Revalidated(age) + public fun error(error: StoreError, servedStale: Boolean): StoreResult.Error = StoreResult.Error(error, servedStale) + public fun exception(error: StoreError, cause: Throwable? = null): StoreException = StoreException(error, cause) + public fun fetchError(message: String, cause: Throwable? = null): StoreError.Fetch = StoreError.Fetch(message, cause) + public fun persistenceError(message: String, cause: Throwable? = null): StoreError.Persistence = StoreError.Persistence(message, cause) + public fun conversionError(message: String, cause: Throwable? = null): StoreError.Conversion = StoreError.Conversion(message, cause) + public fun freshnessUnsatisfiable(message: String): StoreError.FreshnessUnsatisfiable = StoreError.FreshnessUnsatisfiable(message) + public fun conflict(serverMeta: StoreMeta?, message: String): StoreError.Conflict = StoreError.Conflict(serverMeta, message) + public fun missing(key: StoreKey, message: String): StoreError.Missing = StoreError.Missing(key, message) +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreRuntime.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreRuntime.kt new file mode 100644 index 000000000..250d5bc0a --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreRuntime.kt @@ -0,0 +1,39 @@ +package org.mobilenativefoundation.store6.core.seam + +import kotlinx.coroutines.flow.Flow +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.internal.RealStore + +/** + * Optional engine-backed capabilities exposed to Store extensions without implementation downcasts. + * + * @param K the key type accepted by the owning Store + * @param V the non-null value type produced by the owning Store + */ +@ExperimentalStoreApi +@SubclassOptInRequired(DelicateStoreApi::class) +public interface StoreRuntime { + /** Engine-backed acknowledgement and freshness capability. */ + public val writeHandle: StoreWriteHandle + + /** Best-effort advisory engine events; this hot flow never completes, even after Store close. */ + public val keyEvents: Flow + + /** Exact telemetry sink configured at build time, or `null` when telemetry is unset. */ + public val telemetry: StoreTelemetry? +} + +/** + * Returns this Store's engine-backed capability handle. + * + * Non-engine stores, fakes, and decorators return `null`; a decorator exposes its own affordances. + * The single unchecked cast is contained inside the library. + */ +@ExperimentalStoreApi +public fun Store.runtime(): StoreRuntime? { + @Suppress("UNCHECKED_CAST") + return (this as? RealStore)?.runtime +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreTelemetry.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreTelemetry.kt new file mode 100644 index 000000000..c1deee2f9 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreTelemetry.kt @@ -0,0 +1,55 @@ +package org.mobilenativefoundation.store6.core.seam + +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreKey +import kotlin.time.Duration + +/** + * Observes Store lifecycle events without participating in Store correctness. + * + * Handlers are non-suspending and must be non-blocking and must not throw. The engine never invokes + * them while its state or write lock is held. [onServe] runs for every public data emission and + * successful `get` return. For a revalidation it runs only when the rendered projection retains a + * visible value: pass-through uses the effective authorized origin, an overlaid value uses + * `Origin.OVERLAY`, and projected absence has no successful serve and therefore no hook. + * + * When telemetry is unset the engine retains a null reference, every call site short-circuits with + * a null guard, and no fetch-duration mark is allocated. When configured, [onFetchStarted] runs at + * fetch-coroutine start. [onFetchSucceeded] or [onFetchFailed] runs after commit or settlement and + * before the fetch ticket completes, so every resumed waiter observes the terminal hook first. + * Superseded fetches have no terminal hook. + */ +@ExperimentalStoreApi +@SubclassOptInRequired(DelicateStoreApi::class) +public interface StoreTelemetry { + /** Observes the start of a fetch attempt for [key]. */ + public fun onFetchStarted(key: StoreKey) {} + + /** Observes successful fetch commit or revalidation for [key]. */ + public fun onFetchSucceeded( + key: StoreKey, + duration: Duration, + ) {} + + /** Observes terminal fetch [error] for [key]. */ + public fun onFetchFailed( + key: StoreKey, + error: StoreError, + duration: Duration, + ) {} + + /** Observes a successful public serve of [key] from [origin]. */ + public fun onServe( + key: StoreKey, + origin: Origin, + ) {} + + /** Observes successful invalidation of [key]. */ + public fun onInvalidated(key: StoreKey) {} + + /** Observes successful clearing of [key]. */ + public fun onCleared(key: StoreKey) {} +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreWriteHandle.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreWriteHandle.kt new file mode 100644 index 000000000..ba0955286 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreWriteHandle.kt @@ -0,0 +1,55 @@ +package org.mobilenativefoundation.store6.core.seam + +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreException +import org.mobilenativefoundation.store6.core.StoreKey + +/** + * Engine-backed acknowledgement path for committing and refreshing source-of-truth values. + * + * @param K the key type accepted by the owning Store + * @param V the non-null value type committed by the owning Store + */ +@ExperimentalStoreApi +@SubclassOptInRequired(DelicateStoreApi::class) +public interface StoreWriteHandle { + /** + * Commits [value] to the source of truth for [key] under the engine write lock. + * + * This acknowledgement path publishes synthetic-ticket attribution from committing through + * committed so streams observe `Data(origin = SOT)`. It never fetches or calls the network and + * does not record bookkeeping success; callers pair it with [confirmFresh] when the value is + * known fresh. + * + * Cancellation propagates after the synthetic owner state is terminalized. Other persistence + * failures throw [StoreException] carrying [StoreError.Persistence], retain the original cause, + * and leave engine state safe and unchanged. + */ + public suspend fun apply( + key: K, + value: V, + ) + + /** + * Marks [key] durably stale with semantics identical to `Store.invalidate(key)`. + * + * Active streams are signaled to refetch, and the engine produces both + * [KeyEvents.Invalidated] and the configured invalidation telemetry callback. + */ + public suspend fun markStale(key: K) + + /** + * Confirms the resident value for [key] as fresh without fetching. + * + * When residence exists, this records bookkeeping success, clears durable staleness like a + * `304 Not Modified`, and refreshes resident metadata and its commit epoch. Active streams may + * observe one data re-emission with refreshed flags. With no resident value this does nothing. + * This call alone is not an observation mechanism. + */ + public suspend fun confirmFresh( + key: K, + etag: String?, + ) +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/TransactionalSourceOfTruth.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/TransactionalSourceOfTruth.kt new file mode 100644 index 000000000..369d7e14e --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/TransactionalSourceOfTruth.kt @@ -0,0 +1,22 @@ +package org.mobilenativefoundation.store6.core.seam + +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey + +/** + * Optional atomicity capability for a [SourceOfTruth]. Detectable via + * `sot is TransactionalSourceOfTruth`; the engine never assumes it and there is deliberately no + * silent non-atomic default. + * + * @param K the key type used to locate a row + * @param V the non-null row type + */ +@ExperimentalStoreApi +@SubclassOptInRequired(DelicateStoreApi::class) +public interface TransactionalSourceOfTruth : SourceOfTruth { + /** Runs [block] atomically with respect to writes made through this source. */ + // docs:snippet:guides-persistence-transaction-signature + public suspend fun withTransaction(block: suspend () -> R): R + // docs:snippet:end +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/WallClock.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/WallClock.kt new file mode 100644 index 000000000..1ce8dc915 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/WallClock.kt @@ -0,0 +1,20 @@ +package org.mobilenativefoundation.store6.core.seam + +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi + +/** + * Supplies wall-clock time only for age and bounds calculations. Implementations must be cheap and + * non-blocking. + * + * The store-global monotone success sequence handles ordering; implementations must not use wall + * time as an ordering substitute. + */ +// docs:snippet:guides-extending-wall-clock-seam +@ExperimentalStoreApi +@SubclassOptInRequired(DelicateStoreApi::class) +public interface WallClock { + /** Returns the current wall-clock time in milliseconds since the Unix epoch. */ + public fun nowEpochMillis(): Long +} +// docs:snippet:end diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/EmissionSequenceConformanceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/EmissionSequenceConformanceTest.kt new file mode 100644 index 000000000..6d27bdb1e --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/EmissionSequenceConformanceTest.kt @@ -0,0 +1,333 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.test +import app.cash.turbine.testIn +import app.cash.turbine.turbineScope +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import kotlinx.coroutines.withContext +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +open class EmissionSequenceConformanceTest : SourceOfTruthSubstitutionTest() { + @Test + fun ac1a_staleWhileRevalidate_successEmitsStaleThenExactlyOneFreshData() = runTest { + var calls = 0 + val secondStarted = CompletableDeferred() + val secondGate = CompletableDeferred() + val store = testStore { + fetcher { + when (++calls) { + 1 -> "v1" + 2 -> { + secondStarted.complete(Unit) + secondGate.await() + "v2" + } + else -> error("unexpected fetch call $calls") + } + } + } + + try { + val key = TestKey("1") + turbineScope { + // A retained LocalOnly observer makes the empty-reader boundary public and + // byte-identical across every substituted SourceOfTruth. + val localCollector = + store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + val missing = assertIs(localCollector.awaitItem()) + assertIs(missing.error) + assertFalse(missing.servedStale) + assertEquals(0, calls) + awaitCurrentReaderFirstDelivery(key) + + val initialCollector = store.stream(key).testIn(backgroundScope) + assertIs(initialCollector.awaitItem()) + val initial = assertIs>(initialCollector.awaitItem()) + assertEquals("v1", initial.value) + assertFalse(initial.isStale) + assertFalse(initial.refreshing) + assertEquals(1, calls) + val localInitial = assertIs>(localCollector.awaitItem()) + assertEquals("v1", localInitial.value) + assertFalse(localInitial.isStale) + assertFalse(localInitial.refreshing) + + store.invalidate(key) + // Prove the retained seed collector has processed invalidation and registered + // the gated refetch before the target collector joins it. Otherwise a delayed + // seed watcher can first run in the slot-settle/write-through I3 window. + secondStarted.awaitFromDefault() + val collector = store.stream(key).testIn(backgroundScope) + val stale = assertIs>(collector.awaitItem()) + assertEquals("v1", stale.value) + assertTrue(stale.isStale) + assertTrue(stale.refreshing) + + secondGate.complete(Unit) + + var fresh = assertIs>(collector.awaitItem()) + var queuedStaleReplays = 0 + while (fresh.value == "v1") { + // At-least-latest Data permits a queued stale replay to reach a fast + // collector; it may not replace or follow the one clean terminal value. + queuedStaleReplays += 1 + assertTrue( + queuedStaleReplays <= QUEUED_STALE_REPLAY_BOUND, + "queued stale replays exceeded the ratified bound", + ) + assertEquals("v1", fresh.value) + assertTrue(fresh.isStale) + assertTrue(fresh.refreshing) + fresh = assertIs>(collector.awaitItem()) + } + assertEquals("v2", fresh.value) + assertFalse(fresh.isStale) + assertFalse(fresh.refreshing) + collector.expectNoEvents() + assertEquals(2, calls) + localCollector.cancelAndIgnoreRemainingEvents() + initialCollector.cancelAndIgnoreRemainingEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } finally { + secondGate.complete(Unit) + store.closeAndSettleForTest() + } + } + + @Test + fun ac1b_staleWhileRevalidate_failureEmitsStaleThenExactlyOneServedStaleError() = runTest { + var calls = 0 + val boom = IllegalStateException("boom") + val secondStarted = CompletableDeferred() + val secondGate = CompletableDeferred() + val store = testStore { + fetcher { + when (++calls) { + 1 -> "v1" + 2 -> { + secondStarted.complete(Unit) + secondGate.await() + throw boom + } + else -> error("unexpected fetch call $calls") + } + } + } + + try { + assertEquals("v1", store.get(TestKey("1"))) + store.invalidate(TestKey("1")) + + store.stream(TestKey("1")).test { + val stale = assertIs>(awaitItem()) + assertEquals("v1", stale.value) + assertTrue(stale.isStale) + assertTrue(stale.refreshing) + + secondStarted.awaitFromDefault() + secondGate.complete(Unit) + + var terminal = awaitItem() + var queuedStaleReplays = 0 + while (terminal is StoreResult.Data<*>) { + queuedStaleReplays += 1 + assertTrue( + queuedStaleReplays <= QUEUED_STALE_REPLAY_BOUND, + "queued stale replays exceeded the ratified bound", + ) + assertEquals("v1", terminal.value) + assertTrue(terminal.isStale) + assertTrue(terminal.refreshing) + terminal = awaitItem() + } + val failure = assertIs(terminal) + val fetch = assertIs(failure.error) + assertTrue(fetch.cause === boom) + assertTrue(failure.servedStale) + expectNoEvents() + assertEquals(2, calls) + cancelAndIgnoreRemainingEvents() + } + } finally { + secondGate.complete(Unit) + store.closeAndSettleForTest() + } + } + + @Test + fun ac1c_invalidationRacingInitialFailureEmitsOneErrorThenSecondCycleData() = runTest { + var calls = 0 + val boom = IllegalStateException("boom") + val firstStarted = CompletableDeferred() + val firstGate = CompletableDeferred() + val secondStarted = CompletableDeferred() + val secondGate = CompletableDeferred() + val store = testStore { + fetcherOfResult { + when (++calls) { + 1 -> { + firstStarted.complete(Unit) + firstGate.await() + FetcherResult.Error(boom) + } + 2 -> { + secondStarted.complete(Unit) + secondGate.await() + FetcherResult.Success("v2") + } + else -> error("unexpected fetch call $calls") + } + } + } + + try { + store.stream(TestKey("1")).test { + assertIs(awaitItem()) + firstStarted.awaitFromDefault() + + store.invalidate(TestKey("1")) + firstGate.complete(Unit) + + val failure = assertIs(awaitItem()) + val fetch = assertIs(failure.error) + assertTrue(fetch.cause === boom) + assertFalse(failure.servedStale) + + secondStarted.awaitFromDefault() + expectNoEvents() + secondGate.complete(Unit) + + val fresh = assertIs>(awaitItem()) + assertEquals("v2", fresh.value) + assertFalse(fresh.isStale) + assertFalse(fresh.refreshing) + expectNoEvents() + assertEquals(2, calls) + cancelAndIgnoreRemainingEvents() + } + } finally { + firstGate.complete(Unit) + secondGate.complete(Unit) + store.closeAndSettleForTest() + } + } + + @Test + fun ac1d_notModifiedEmitsExactlyOneRevalidatedWithoutFreshData() = runTest { + var calls = 0 + val secondStarted = CompletableDeferred() + val secondGate = CompletableDeferred() + val key = TestKey("1") + val store = testStore { + fetcherOfResult { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> { + secondStarted.complete(Unit) + secondGate.await() + FetcherResult.NotModified(etag = "e1") + } + // A cold-baseline 304 commits ObsoleteRevalidation and legally self-heals + // with exactly one replanned conditional fetch. + 3 -> FetcherResult.NotModified(etag = "e1") + else -> error("unexpected fetch call $calls") + } + } + } + + try { + turbineScope { + // A retained LocalOnly observer makes the empty-reader boundary public and + // byte-identical across every substituted SourceOfTruth. + val localCollector = + store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + val missing = assertIs(localCollector.awaitItem()) + assertIs(missing.error) + assertFalse(missing.servedStale) + assertEquals(0, calls) + awaitCurrentReaderFirstDelivery(key) + + val initialCollector = store.stream(key).testIn(backgroundScope) + assertIs(initialCollector.awaitItem()) + val initial = assertIs>(initialCollector.awaitItem()) + assertEquals("v1", initial.value) + assertFalse(initial.isStale) + assertFalse(initial.refreshing) + assertEquals(1, calls) + val localInitial = assertIs>(localCollector.awaitItem()) + assertEquals("v1", localInitial.value) + assertFalse(localInitial.isStale) + assertFalse(localInitial.refreshing) + + store.invalidate(key) + secondStarted.awaitFromDefault() + val collector = store.stream(key).testIn(backgroundScope) + val stale = assertIs>(collector.awaitItem()) + assertEquals("v1", stale.value) + assertTrue(stale.isStale) + assertTrue(stale.refreshing) + + secondGate.complete(Unit) + + var terminal = collector.awaitItem() + var queuedStaleReplays = 0 + while (terminal is StoreResult.Data<*>) { + // A queued pre-304 replay may survive as Data, but it must remain the stale + // baseline; the owner-visible fresh terminal is exclusively Revalidated. + queuedStaleReplays += 1 + assertTrue( + queuedStaleReplays <= QUEUED_STALE_REPLAY_BOUND, + "queued stale replays exceeded the ratified bound", + ) + assertEquals("v1", terminal.value) + assertTrue(terminal.isStale) + assertTrue(terminal.refreshing) + terminal = collector.awaitItem() + } + assertIs(terminal) + collector.expectNoEvents() + assertTrue( + calls in 2..3, + "the 304 cycle may self-heal one obsolete cold-baseline launch", + ) + localCollector.cancelAndIgnoreRemainingEvents() + initialCollector.cancelAndIgnoreRemainingEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } finally { + secondGate.complete(Unit) + store.closeAndSettleForTest() + } + } +} + +private const val QUEUED_STALE_REPLAY_BOUND = 1 + +// Turbine's 3s default would nest inside the 25s shadow. Raising the Turbine deadline above +// the shadow makes runTest the only effective timeout. +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds // strictly > TEST_TIMEOUT: the shadow must fire first + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } + +// Preserve Default-dispatch ordering and let the suite-level runTest bound own cancellation. +private suspend fun CompletableDeferred.awaitFromDefault(): T = + withContext(Dispatchers.Default) { + await() + } diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/FetcherContractTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/FetcherContractTest.kt new file mode 100644 index 000000000..abfa34dfe --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/FetcherContractTest.kt @@ -0,0 +1,268 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.test +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.internal.InMemoryBookkeeper +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalStoreApi::class) +class FetcherContractTest { + @Test + fun fetcherResultError_cancellationIsEquivalentToThrowingCancellation() = runTest { + val returnedCancellation = CancellationException("fetch cancelled") + val richStore = store { + fetcherOfResult { FetcherResult.Error(returnedCancellation) } + } + val thrownCancellation = CancellationException("fetch cancelled") + val plainStore = store { + fetcher { throw thrownCancellation } + } + + try { + val returnedFailure = + assertFailsWith { + richStore.get(TestKey("1")) + } + assertEquals("fetch cancelled", returnedFailure.message) + assertTrue( + returnedFailure === returnedCancellation || + returnedFailure.cause === returnedCancellation, + ) + + val thrownFailure = + assertFailsWith { + plainStore.get(TestKey("1")) + } + assertEquals("fetch cancelled", thrownFailure.message) + assertTrue( + thrownFailure === thrownCancellation || thrownFailure.cause === thrownCancellation, + ) + } finally { + richStore.close() + plainStore.close() + } + } + + @Test + fun richSuccess_streamEmitsLoadingThenFetcherData() = runTest { + val store = store { + fetcherOfResult { FetcherResult.Success("v", etag = "e1") } + } + + try { + store.stream(TestKey("1")).test { + assertIs(awaitItem()) + val data = assertIs>(awaitItem()) + assertEquals("v", data.value) + assertEquals(Origin.FETCHER, data.origin) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun richError_matchesThrownFetcherFailureForGetAndStream() = runTest { + val boom = IllegalStateException("boom") + val store = store { + fetcherOfResult { FetcherResult.Error(boom) } + } + + try { + val getFailure = assertFailsWith { store.get(TestKey("1")) } + val getFetch = assertIs(getFailure.error) + assertTrue(getFetch.cause === boom) + + store.stream(TestKey("1")).test { + assertIs(awaitItem()) + val failure = assertIs(awaitItem()) + val streamFetch = assertIs(failure.error) + assertTrue(streamFetch.cause === boom) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun deletedAfterResident_emitsOneLoadingAndOneMissingWithoutLoop() = runTest { + var calls = 0 + val store = store { + fetcherOfResult { + when (++calls) { + 1 -> FetcherResult.Success("v1") + 2, 3 -> FetcherResult.Deleted + else -> error("unexpected fetch call $calls") + } + } + } + val key = TestKey("1") + + try { + assertEquals("v1", store.get(key)) + + store.stream(key).test { + assertEquals("v1", assertIs>(awaitItem()).value) + store.invalidate(key) + + val events = listOf(awaitItem(), awaitItem()) + assertEquals(1, events.count { it is StoreResult.Loading }) + val failures = events.filterIsInstance() + assertEquals(1, failures.size) + val missing = assertIs(failures.single().error) + assertTrue(missing.message.lowercase().contains("deleted")) + expectNoEvents() + + val laterFailure = assertFailsWith { store.get(key) } + val laterMissing = assertIs(laterFailure.error) + assertTrue(laterMissing.message.lowercase().contains("deleted")) + assertEquals(3, calls) + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun deletedOnFirstGet_reportsMissing() = runTest { + val store = store { + fetcherOfResult { FetcherResult.Deleted } + } + + try { + val failure = assertFailsWith { store.get(TestKey("1")) } + val missing = assertIs(failure.error) + assertTrue(missing.message.lowercase().contains("deleted")) + } finally { + store.close() + } + } + + @Test + fun deletedOnFirstStream_emitsLoadingThenMissingAndStaysLive() = runTest { + val store = store { + fetcherOfResult { FetcherResult.Deleted } + } + + try { + store.stream(TestKey("1")).test { + assertIs(awaitItem()) + val failure = assertIs(awaitItem()) + val missing = assertIs(failure.error) + assertTrue(missing.message.lowercase().contains("deleted")) + assertEquals(false, failure.servedStale) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun notModifiedWithoutResident_reportsMissingForGetAndStream() = runTest { + val store = store { + fetcherOfResult { FetcherResult.NotModified(etag = "e1") } + } + + try { + val getFailure = assertFailsWith { store.get(TestKey("1")) } + val getMissing = assertIs(getFailure.error) + assertTrue(getMissing.message.contains("NotModified")) + + store.stream(TestKey("1")).test { + assertIs(awaitItem()) + val failure = assertIs(awaitItem()) + val streamMissing = assertIs(failure.error) + assertTrue(streamMissing.message.contains("NotModified")) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun successNotModifiedAndDeleted_updateAndForgetBookkeeper() = runTest { + var calls = 0 + val bookkeeper = InMemoryBookkeeper() + val clock = FakeWallClock(now = 1_000L) + val store = storeWith(clock = clock, bookkeeper = bookkeeper) { + fetcherOfResult { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> FetcherResult.NotModified(etag = "e2") + 3 -> FetcherResult.Deleted + else -> error("unexpected fetch call $calls") + } + } + } + val key = TestKey("1") + + try { + assertEquals("v1", store.get(key)) + val seeded = assertNotNull(bookkeeper.status(key)) + assertEquals("e1", seeded.meta?.etag) + assertEquals(1L, seeded.lastSuccessSequence) + + clock.now = 2_000L + assertEquals("v1", store.get(key, Freshness.MustBeFresh)) + val revalidated = assertNotNull(bookkeeper.status(key)) + assertEquals("e2", revalidated.meta?.etag) + assertEquals(2L, revalidated.lastSuccessSequence) + assertTrue( + assertNotNull(revalidated.lastSuccessSequence) > + assertNotNull(seeded.lastSuccessSequence), + ) + + val deleted = + assertFailsWith { + store.get(key, Freshness.MustBeFresh) + } + val missing = assertIs(deleted.error) + assertTrue(missing.message.lowercase().contains("deleted")) + assertNull(bookkeeper.status(key)) + assertEquals(3, calls) + } finally { + store.close() + } + } + + @Test + fun notModifiedWithNullEtag_retainsPreviousEtag() = runTest { + var calls = 0 + val bookkeeper = InMemoryBookkeeper() + val store = storeWith(bookkeeper = bookkeeper) { + fetcherOfResult { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> FetcherResult.NotModified(etag = null) + else -> error("unexpected fetch call $calls") + } + } + } + val key = TestKey("1") + + try { + assertEquals("v1", store.get(key)) + assertEquals("v1", store.get(key, Freshness.MustBeFresh)) + assertEquals("e1", bookkeeper.status(key)?.meta?.etag) + } finally { + store.close() + } + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/FreshnessPolicyConformanceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/FreshnessPolicyConformanceTest.kt new file mode 100644 index 000000000..68a67dbfc --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/FreshnessPolicyConformanceTest.kt @@ -0,0 +1,504 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.test +import app.cash.turbine.testIn +import app.cash.turbine.turbineScope +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import kotlinx.coroutines.withContext +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalStoreApi::class) +open class FreshnessPolicyConformanceTest : SourceOfTruthSubstitutionTest() { + @Test + fun maxAgeWithinBoundServesResidentWithoutSecondFetch() = runTest { + val clock = FakeWallClock(now = 0L) + var calls = 0 + val store = testStoreWith(clock = clock) { + fetcher { "v${++calls}" } + } + + try { + assertEquals("v1", store.get(TestKey("1"))) + clock.now = 60.seconds.inWholeMilliseconds + + assertEquals( + "v1", + store.get(TestKey("1"), Freshness.MaxAge(notOlderThan = 5.minutes)), + ) + assertEquals(1, calls) + } finally { + store.closeAndSettleForTest() + } + } + + @Test + fun maxAgeOverBoundGetBlocksForAndReturnsFreshValue() = runTest { + val clock = FakeWallClock(now = 0L) + var calls = 0 + val secondStarted = CompletableDeferred() + val secondGate = CompletableDeferred() + val store = testStoreWith(clock = clock) { + fetcher { + when (++calls) { + 1 -> "v1" + 2 -> { + secondStarted.complete(Unit) + secondGate.await() + "v2" + } + else -> error("unexpected fetch call $calls") + } + } + } + + try { + assertEquals("v1", store.get(TestKey("1"))) + clock.now = 600.seconds.inWholeMilliseconds + + val fresh = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.get(TestKey("1"), Freshness.MaxAge(notOlderThan = 5.minutes)) + } + assertFalse(fresh.isCompleted) + secondStarted.awaitFromDefault() + secondGate.complete(Unit) + + assertEquals("v2", fresh.await()) + assertEquals(2, calls) + } finally { + secondGate.complete(Unit) + store.closeAndSettleForTest() + } + } + + @Test + fun maxAgeOverBoundStreamWithholdsResidentUntilFreshValue() = runTest { + val clock = FakeWallClock(now = 0L) + var calls = 0 + val secondStarted = CompletableDeferred() + val secondGate = CompletableDeferred() + val store = testStoreWith(clock = clock) { + fetcher { + when (++calls) { + 1 -> "v1" + 2 -> { + secondStarted.complete(Unit) + secondGate.await() + "v2" + } + else -> error("unexpected fetch call $calls") + } + } + } + + try { + val key = TestKey("1") + turbineScope { + // A retained LocalOnly observer makes the empty-reader boundary public and + // byte-identical across every substituted SourceOfTruth. + val localCollector = + store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + val missing = assertIs(localCollector.awaitItem()) + assertIs(missing.error) + assertFalse(missing.servedStale) + assertEquals(0, calls) + awaitCurrentReaderFirstDelivery(key) + + val initialCollector = store.stream(key).testIn(backgroundScope) + assertIs(initialCollector.awaitItem()) + val initial = assertIs>(initialCollector.awaitItem()) + assertEquals("v1", initial.value) + assertFalse(initial.isStale, "seed frame must not be stale: $initial") + assertFalse( + initial.refreshing, + "seed frame must not remain refreshing: $initial", + ) + assertEquals(1, calls) + val localInitial = assertIs>(localCollector.awaitItem()) + assertEquals("v1", localInitial.value) + assertFalse(localInitial.isStale) + assertFalse(localInitial.refreshing) + clock.now = 600.seconds.inWholeMilliseconds + + val collector = + store.stream( + key, + Freshness.MaxAge(notOlderThan = 5.minutes), + ).testIn(backgroundScope) + assertIs(collector.awaitItem()) + secondStarted.awaitFromDefault() + secondGate.complete(Unit) + + val fresh = assertIs>(collector.awaitItem()) + assertEquals("v2", fresh.value) + assertEquals(Duration.ZERO, fresh.age) + assertFalse(fresh.isStale, "fresh frame must not be stale: $fresh") + assertFalse( + fresh.refreshing, + "fresh terminal must not remain refreshing: $fresh", + ) + collector.expectNoEvents() + localCollector.cancelAndIgnoreRemainingEvents() + initialCollector.cancelAndIgnoreRemainingEvents() + collector.cancelAndIgnoreRemainingEvents() + } + assertEquals(2, calls) + } finally { + secondGate.complete(Unit) + store.closeAndSettleForTest() + } + } + + @Test + fun maxAgeOverBoundFailureDoesNotFallBackForGetOrStream() = runTest { + val clock = FakeWallClock(now = 0L) + val boom = IllegalStateException("boom") + var calls = 0 + val secondStarted = CompletableDeferred() + val secondGate = CompletableDeferred() + val thirdStarted = CompletableDeferred() + val thirdGate = CompletableDeferred() + val store = testStoreWith(clock = clock) { + fetcher { + when (++calls) { + 1 -> "v1" + 2 -> { + secondStarted.complete(Unit) + secondGate.await() + throw boom + } + 3 -> { + thirdStarted.complete(Unit) + thirdGate.await() + throw boom + } + else -> error("unexpected fetch call $calls") + } + } + } + + try { + assertEquals("v1", store.get(TestKey("1"))) + clock.now = 600.seconds.inWholeMilliseconds + + val get = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { + store.get(TestKey("1"), Freshness.MaxAge(notOlderThan = 5.minutes)) + } + } + assertFalse(get.isCompleted) + secondStarted.awaitFromDefault() + secondGate.complete(Unit) + + val getFailure = assertIs(get.await().exceptionOrNull()) + val getFetch = assertIs(getFailure.error) + assertTrue(getFetch.cause === boom) + + store.stream( + TestKey("1"), + Freshness.MaxAge(notOlderThan = 5.minutes), + ).test { + assertIs(awaitItem()) + thirdStarted.awaitFromDefault() + thirdGate.complete(Unit) + + val failure = assertIs(awaitItem()) + val streamFetch = assertIs(failure.error) + assertTrue(streamFetch.cause === boom) + assertFalse(failure.servedStale) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + assertEquals(3, calls) + } finally { + secondGate.complete(Unit) + thirdGate.complete(Unit) + store.closeAndSettleForTest() + } + } + + @Test + fun mustBeFreshRefetchesFreshResident() = runTest { + var calls = 0 + val store = testStore { + fetcher { "v${++calls}" } + } + + try { + assertEquals("v1", store.get(TestKey("1"))) + assertEquals("v2", store.get(TestKey("1"), Freshness.MustBeFresh)) + assertEquals(2, calls) + } finally { + store.closeAndSettleForTest() + } + } + + @Test + fun mustBeFreshFailureHasNoFallbackAndStreamCompletes() = runTest { + val boom = IllegalStateException("boom") + var calls = 0 + val store = testStore { + fetcher { + if (++calls == 1) "v1" else throw boom + } + } + + try { + assertEquals("v1", store.get(TestKey("1"))) + + val getFailure = + assertFailsWith { + store.get(TestKey("1"), Freshness.MustBeFresh) + } + val getFetch = assertIs(getFailure.error) + assertTrue(getFetch.cause === boom) + + store.stream(TestKey("1"), Freshness.MustBeFresh).test { + assertIs(awaitItem()) + val failure = assertIs(awaitItem()) + val streamFetch = assertIs(failure.error) + assertTrue(streamFetch.cause === boom) + assertFalse(failure.servedStale) + awaitComplete() + } + } finally { + store.closeAndSettleForTest() + } + } + + @Test + fun staleIfErrorAfterInvalidationWaitsForFailureThenReturnsResident() = runTest { + var calls = 0 + val secondStarted = CompletableDeferred() + val secondGate = CompletableDeferred() + val boom = IllegalStateException("boom") + val store = testStore { + fetcher { + when (++calls) { + 1 -> "v1" + 2 -> { + secondStarted.complete(Unit) + secondGate.await() + throw boom + } + else -> error("unexpected fetch call $calls") + } + } + } + + try { + assertEquals("v1", store.get(TestKey("1"))) + store.invalidate(TestKey("1")) + + val fallback = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.get(TestKey("1"), Freshness.StaleIfError) + } + assertFalse(fallback.isCompleted) + secondStarted.awaitFromDefault() + secondGate.complete(Unit) + + assertEquals("v1", fallback.await()) + assertEquals(2, calls) + } finally { + secondGate.complete(Unit) + store.closeAndSettleForTest() + } + } + + @Test + fun staleIfErrorWithoutResidentThrowsFetch() = runTest { + val boom = IllegalStateException("boom") + var calls = 0 + val store = testStore { + fetcher { + calls++ + throw boom + } + } + + try { + val failure = + assertFailsWith { + store.get(TestKey("1"), Freshness.StaleIfError) + } + val fetch = assertIs(failure.error) + assertTrue(fetch.cause === boom) + assertEquals(1, calls) + } finally { + store.closeAndSettleForTest() + } + } + + @Test + fun staleIfErrorStreamEmitsStaleThenServedStaleErrorAndStaysLive() = runTest { + var calls = 0 + val secondStarted = CompletableDeferred() + val secondGate = CompletableDeferred() + val boom = IllegalStateException("boom") + val store = testStore { + fetcher { + when (++calls) { + 1 -> "v1" + 2 -> { + secondStarted.complete(Unit) + secondGate.await() + throw boom + } + else -> error("unexpected fetch call $calls") + } + } + } + + try { + assertEquals("v1", store.get(TestKey("1"))) + store.invalidate(TestKey("1")) + + store.stream(TestKey("1"), Freshness.StaleIfError).test { + val stale = assertIs>(awaitItem()) + assertEquals("v1", stale.value) + assertTrue(stale.isStale) + assertTrue(stale.refreshing) + + secondStarted.awaitFromDefault() + secondGate.complete(Unit) + + var terminal = awaitItem() + var queuedStaleReplays = 0 + while (terminal is StoreResult.Data<*>) { + queuedStaleReplays += 1 + assertTrue( + queuedStaleReplays <= QUEUED_STALE_REPLAY_BOUND, + "queued stale replays exceeded the ratified bound", + ) + assertEquals("v1", terminal.value) + assertTrue(terminal.isStale) + assertTrue(terminal.refreshing) + terminal = awaitItem() + } + val failure = assertIs(terminal) + val fetch = assertIs(failure.error) + assertTrue(fetch.cause === boom) + assertTrue(failure.servedStale) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + assertEquals(2, calls) + } finally { + secondGate.complete(Unit) + store.closeAndSettleForTest() + } + } + + @Test + fun localOnlyWithoutResidentReportsMissingWithoutLoadingOrFetcherCall() = runTest { + var calls = 0 + val store = testStore { + fetcher { + calls++ + "remote" + } + } + + try { + val getFailure = + assertFailsWith { + store.get(TestKey("1"), Freshness.LocalOnly) + } + val getMissing = assertIs(getFailure.error) + assertTrue(getMissing.message.contains("LocalOnly")) + + store.stream(TestKey("1"), Freshness.LocalOnly).test { + val failure = assertIs(awaitItem()) + val streamMissing = assertIs(failure.error) + assertTrue(streamMissing.message.contains("LocalOnly")) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + assertEquals(0, calls) + } finally { + store.closeAndSettleForTest() + } + } + + @Test + fun localOnlyResidentIgnoresInvalidationForGetAndStream() = runTest { + var calls = 0 + val store = testStore { + fetcher { "v${++calls}" } + } + val key = TestKey("1") + + try { + assertEquals("v1", store.get(key)) + + store.stream(key, Freshness.LocalOnly).test { + assertEquals("v1", assertIs>(awaitItem()).value) + store.invalidate(key) + + assertEquals("v1", store.get(key, Freshness.LocalOnly)) + expectNoEvents() + assertEquals(1, calls) + cancelAndIgnoreRemainingEvents() + } + } finally { + store.closeAndSettleForTest() + } + } + + @Test + fun dataAgeUsesInjectedWallClock() = runTest { + val clock = FakeWallClock(now = 1_000L) + val store = testStoreWith(clock = clock) { + fetcher { "v" } + } + + try { + assertEquals("v", store.get(TestKey("1"))) + clock.now = 31_000L + + store.stream(TestKey("1")).test { + val data = assertIs>(awaitItem()) + assertEquals(30.seconds, data.age) + cancelAndIgnoreRemainingEvents() + } + } finally { + store.closeAndSettleForTest() + } + } +} + +private const val QUEUED_STALE_REPLAY_BOUND = 1 + +// Turbine's 3s default would nest inside the 25s shadow. Raising the Turbine deadline above +// the shadow makes runTest the only effective timeout. +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds // strictly > TEST_TIMEOUT: the shadow must fire first + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } + +// Preserve Default-dispatch ordering and let the suite-level runTest bound own cancellation. +private suspend fun CompletableDeferred.awaitFromDefault(): T = + withContext(Dispatchers.Default) { + await() + } diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/KeyEngineSourceOfTruthRaceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/KeyEngineSourceOfTruthRaceTest.kt new file mode 100644 index 000000000..22de08072 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/KeyEngineSourceOfTruthRaceTest.kt @@ -0,0 +1,656 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.test +import app.cash.turbine.testIn +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest as coroutineRunTest +import kotlinx.coroutines.withContext +import org.mobilenativefoundation.store6.core.internal.DefaultFreshnessValidator +import org.mobilenativefoundation.store6.core.internal.FetchDisposition +import org.mobilenativefoundation.store6.core.internal.FetchOutcome +import org.mobilenativefoundation.store6.core.internal.FetchSlot +import org.mobilenativefoundation.store6.core.internal.InMemorySourceOfTruth +import org.mobilenativefoundation.store6.core.internal.InMemoryBookkeeper +import org.mobilenativefoundation.store6.core.internal.KeyEngine +import org.mobilenativefoundation.store6.core.internal.KeyId +import org.mobilenativefoundation.store6.core.internal.ResultFetcher +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import org.mobilenativefoundation.store6.core.seam.KeyStatus +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class, ExperimentalCoroutinesApi::class) +class KeyEngineSourceOfTruthRaceTest { + @Test + fun nullMetaHydration_servesLocalOnlyAndStartsOneCachedOrFetchRevalidation() = runTest { + val sourceOfTruth = InMemorySourceOfTruth() + val key = TestKey("key") + sourceOfTruth.write(key, "durable") + val fetched = CompletableDeferred() + var fetchCalls = 0 + val store = store { + persistence(sourceOfTruth) + fetcher { + fetchCalls += 1 + fetched.complete(Unit) + "fresh" + } + } + + try { + assertEquals("durable", store.get(key, Freshness.LocalOnly)) + assertEquals("durable", store.get(key, Freshness.CachedOrFetch)) + fetched.await() + sourceOfTruth.reader(key).filter { it == "fresh" }.first() + runCurrent() + assertEquals(1, fetchCalls) + assertEquals("fresh", store.get(key, Freshness.LocalOnly)) + } finally { + store.close() + } + } + + @Test + fun readerFactoryFailure_isTypedOnceForAnOutageAndRecovers() = runTest { + val sourceOfTruth = FailingReaderSourceOfTruth() + val key = TestKey("key") + val store = store { + persistence(sourceOfTruth) + fetcher { "seed" } + } + + try { + assertEquals("seed", store.get(key)) + sourceOfTruth.beginFactoryOutage(failures = 3) + + store.stream(key, Freshness.LocalOnly).test { + assertEquals("seed", assertIs>(awaitItem()).value) + val failure = assertIs(awaitItem()) + assertIs(failure.error) + + withContext(Dispatchers.Default) { + sourceOfTruth.recovered.await() + } + assertTrue(sourceOfTruth.readerCalls >= 4) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun readerCollectionFailure_isTypedOnceForAContiguousOutageAndRecovers() = runTest { + val sourceOfTruth = FailingReaderSourceOfTruth() + val key = TestKey("key") + val store = store { + persistence(sourceOfTruth) + fetcher { "seed" } + } + + try { + assertEquals("seed", store.get(key)) + sourceOfTruth.beginCollectionOutage(failures = 3, emitBeforeFirstFailure = true) + + store.stream(key, Freshness.LocalOnly).test { + assertEquals("seed", assertIs>(awaitItem()).value) + val failure = assertIs(awaitItem()) + assertIs(failure.error) + + withContext(Dispatchers.Default) { + sourceOfTruth.recovered.await() + } + assertTrue(sourceOfTruth.readerCalls >= 4) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun twoCollectorsShareOneReaderRetryLoop() = runTest { + val sourceOfTruth = FailingReaderSourceOfTruth() + val key = TestKey("key") + val store = store { + persistence(sourceOfTruth) + fetcher { "seed" } + } + + try { + assertEquals("seed", store.get(key)) + sourceOfTruth.beginFactoryOutage(failures = 1) + + app.cash.turbine.turbineScope { + val first = store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + val second = store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(first.awaitItem()).value) + assertEquals("seed", assertIs>(second.awaitItem()).value) + assertIs( + assertIs(first.awaitItem()).error, + ) + assertIs( + assertIs(second.awaitItem()).error, + ) + withContext(Dispatchers.Default) { + sourceOfTruth.recovered.await() + } + assertEquals(2, sourceOfTruth.readerCalls) + first.cancelAndIgnoreRemainingEvents() + second.cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun hydrationReaderFailure_isTypedPersistenceForGetAndRecoversOnNextRead() = runTest { + val boom = IllegalStateException("reader failed") + val sourceOfTruth = AdapterFailureSourceOfTruth(readerFailure = boom) + val store = store { + persistence(sourceOfTruth) + fetcher { "unused" } + } + + try { + val failure = + assertFailsWith { + store.get(TestKey("key"), Freshness.LocalOnly) + } + val persistence = assertIs(failure.error) + assertTrue(persistence.cause === boom) + assertTrue(persistence.message.contains("Durable data could not be observed")) + + sourceOfTruth.recoverReaderWith("durable") + assertEquals("durable", store.get(TestKey("key"), Freshness.LocalOnly)) + } finally { + store.close() + } + } + + @Test + fun persistenceWriteFailure_revokesTag_reportsTypedFailure_andLaterRowIsSot() = runTest { + val boom = IllegalStateException("write rejected") + val sourceOfTruth = GatedFailingWriteSourceOfTruth(failure = boom) + val bookkeeper = RecordingBookkeeper() + val clock = FakeWallClock(now = 100L) + val key = TestKey("key") + val engine = + engine( + key = key, + sourceOfTruth = sourceOfTruth, + bookkeeper = bookkeeper, + clock = clock, + ) { FetcherResult.Success("fetched") } + + val read = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { engine.get(Freshness.CachedOrFetch) } + } + val ticket = assertIs(engine.state.value.fetch).ticket + runCurrent() + sourceOfTruth.writeStarted.await() + assertNotNull(engine.state.value.attribution) + + clock.now = 125L + sourceOfTruth.releaseWrite.complete(Unit) + val failure = assertIs(read.await().exceptionOrNull()) + val persistence = assertIs(failure.error) + assertTrue(persistence.cause === boom) + assertTrue(persistence.message.contains("source of truth rejected the write")) + + assertNull(engine.state.value.attribution) + assertEquals(FetchDisposition.Failed, ticket.disposition.value) + val outcome = assertIs(ticket.outcome.await()) + assertEquals(125L, outcome.atEpochMillis) + assertTrue(outcome.bookkeepingRecorded) + assertIs(outcome.exception.error) + assertEquals( + listOf( + BookkeepingEvent.Failure(KeyId.from(key), atEpochMillis = 125L), + ), + bookkeeper.events, + ) + val status = assertNotNull(bookkeeper.status(key)) + assertEquals(125L, status.lastFailureAtEpochMillis) + assertEquals(1, status.consecutiveFailures) + assertNull(status.meta) + + engine.stream(Freshness.LocalOnly).test { + sourceOfTruth.publishExternal("external") + var external: StoreResult.Data? = null + while (external == null) { + when (val item = awaitItem()) { + is StoreResult.Data -> external = item + is StoreResult.Error -> assertIs(item.error) + is StoreResult.Loading -> Unit + is StoreResult.Revalidated -> error("LocalOnly must not revalidate") + } + } + assertEquals("external", external.value) + assertEquals(Origin.SOT, external.origin) + assertTrue(external.isStale) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun persistenceWriteFailure_queuedClear_forgetWinsAndNoFailureResurfaces() = runTest { + val boom = IllegalStateException("write rejected") + val sourceOfTruth = GatedFailingWriteSourceOfTruth(failure = boom) + val bookkeeper = RecordingBookkeeper() + val clock = FakeWallClock(now = 200L) + val key = TestKey("key") + val engine = + engine( + key = key, + sourceOfTruth = sourceOfTruth, + bookkeeper = bookkeeper, + clock = clock, + ) { FetcherResult.Success("fetched") } + + val read = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { engine.get(Freshness.CachedOrFetch) } + } + val ticket = assertIs(engine.state.value.fetch).ticket + runCurrent() + sourceOfTruth.writeStarted.await() + assertNotNull(engine.state.value.attribution) + val clear = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.clear() + } + runCurrent() + assertEquals(0, sourceOfTruth.deleteCalls) + + clock.now = 225L + sourceOfTruth.releaseWrite.complete(Unit) + val failure = assertIs(read.await().exceptionOrNull()) + val persistence = assertIs(failure.error) + assertTrue(persistence.cause === boom) + clear.await() + runCurrent() + + val outcome = assertIs(ticket.outcome.await()) + assertEquals(225L, outcome.atEpochMillis) + assertTrue(outcome.bookkeepingRecorded) + assertEquals(FetchDisposition.Failed, ticket.disposition.value) + assertEquals( + listOf( + BookkeepingEvent.Failure(KeyId.from(key), atEpochMillis = 225L), + BookkeepingEvent.Forget(KeyId.from(key)), + ), + bookkeeper.events, + ) + assertNull(bookkeeper.status(key)) + assertEquals(1, sourceOfTruth.deleteCalls) + assertNull(sourceOfTruth.current) + val state = engine.state.value + assertEquals(1L, state.staleEpoch) + assertEquals(1L, state.clearEpoch) + assertEquals(1L, state.readerGen) + assertNull(state.attribution) + assertEquals(FetchSlot.Idle, state.fetch) + + val missing = + assertFailsWith { + engine.get(Freshness.LocalOnly) + } + assertIs(missing.error) + runCurrent() + assertEquals(2, bookkeeper.events.size) + } + + @Test + fun clearDeleteFailure_preservesResidenceEpochsAndBookkeeping() = runTest { + val boom = IllegalStateException("delete rejected") + val sourceOfTruth = AdapterFailureSourceOfTruth() + val bookkeeper = RecordingBookkeeper() + val clock = FakeWallClock(now = 300L) + val key = TestKey("key") + val engine = + engine( + key = key, + sourceOfTruth = sourceOfTruth, + bookkeeper = bookkeeper, + clock = clock, + ) { FetcherResult.Success("seed", etag = "seed-etag") } + + assertEquals("seed", engine.get(Freshness.CachedOrFetch)) + engine.invalidate() + val stateBefore = engine.state.value + val statusBefore = assertNotNull(bookkeeper.status(key)) + val eventsBefore = bookkeeper.events.toList() + assertEquals("seed", sourceOfTruth.current) + sourceOfTruth.deleteFailure = boom + + val failure = assertFailsWith { engine.clear() } + val persistence = assertIs(failure.error) + assertTrue(persistence.cause === boom) + assertTrue(persistence.message.contains("retry clear()")) + + val stateAfter = engine.state.value + assertEquals(stateBefore.staleEpoch, stateAfter.staleEpoch) + assertEquals(stateBefore.clearEpoch, stateAfter.clearEpoch) + assertEquals(stateBefore.readerGen, stateAfter.readerGen) + assertEquals(stateBefore.fetch, stateAfter.fetch) + assertTrue(stateAfter.attribution === stateBefore.attribution) + assertTrue(bookkeeper.status(key) === statusBefore) + assertEquals(eventsBefore, bookkeeper.events) + assertEquals(1, sourceOfTruth.deleteCalls) + assertEquals("seed", sourceOfTruth.current) + assertEquals("seed", engine.get(Freshness.LocalOnly)) + } + + @Test + fun successfulReturnConvergesFinalWriterOverMutationEraIntermediate() = runTest { + val sourceOfTruth = GatedWriteSourceOfTruth() + val key = TestKey("key") + val store = store { + persistence(sourceOfTruth) + fetcher { "fetched" } + } + + try { + assertEquals("seed", store.get(key, Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val collector = store.stream(key).testIn(backgroundScope) + assertEquals("seed", assertIs>(collector.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + withContext(Dispatchers.Default) { + sourceOfTruth.writeStarted.await() + } + + sourceOfTruth.publishExternal("external") + runCurrent() + collector.expectNoEvents() + sourceOfTruth.releaseWrite.complete(Unit) + runCurrent() + val committed = assertIs>(collector.awaitItem()) + assertEquals("fetched", committed.value) + assertEquals(Origin.FETCHER, committed.origin) + assertEquals("fetched", store.get(key, Freshness.LocalOnly)) + collector.cancelAndIgnoreRemainingEvents() + } + } finally { + sourceOfTruth.releaseWrite.complete(Unit) + store.close() + } + } + + private fun TestScope.engine( + key: TestKey, + sourceOfTruth: SourceOfTruth, + bookkeeper: Bookkeeper, + clock: FakeWallClock, + fetcher: suspend (TestKey) -> FetcherResult, + ): KeyEngine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher(fetcher), + sot = sourceOfTruth, + bookkeeper = bookkeeper, + validator = DefaultFreshnessValidator, + wallClock = clock, + engineScope = backgroundScope, + ) + + private sealed interface BookkeepingEvent { + data class Success( + val key: KeyId, + val atEpochMillis: Long, + ) : BookkeepingEvent + + data class Failure( + val key: KeyId, + val atEpochMillis: Long, + ) : BookkeepingEvent + + data class Forget( + val key: KeyId, + ) : BookkeepingEvent + } + + private class RecordingBookkeeper : Bookkeeper { + private val delegate = InMemoryBookkeeper() + val events = mutableListOf() + + override suspend fun recordSuccess( + key: StoreKey, + meta: StoreMeta, + ) { + events += BookkeepingEvent.Success(KeyId.from(key), meta.writtenAtEpochMillis) + delegate.recordSuccess(key, meta) + } + + override suspend fun recordFailure( + key: StoreKey, + atEpochMillis: Long, + ) { + events += BookkeepingEvent.Failure(KeyId.from(key), atEpochMillis) + delegate.recordFailure(key, atEpochMillis) + } + + override suspend fun status(key: StoreKey): KeyStatus? = delegate.status(key) + + override suspend fun forget(key: StoreKey) { + events += BookkeepingEvent.Forget(KeyId.from(key)) + delegate.forget(key) + } + + override suspend fun markStale(key: StoreKey) = delegate.markStale(key) + + override suspend fun advanceStaleWatermark(namespace: StoreNamespace) = + delegate.advanceStaleWatermark(namespace) + + override suspend fun advanceGlobalStaleWatermark() = + delegate.advanceGlobalStaleWatermark() + + override suspend fun forgetNamespace(namespace: StoreNamespace) = + delegate.forgetNamespace(namespace) + + override suspend fun forgetAll() = delegate.forgetAll() + } + + private class FailingReaderSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow(null) + private var factoryFailuresRemaining: Int = 0 + private var collectionFailuresRemaining: Int = 0 + private var emitBeforeFirstCollectionFailure: Boolean = false + private var trackingRecovery: Boolean = false + var recovered: CompletableDeferred = CompletableDeferred() + private set + var readerCalls: Int = 0 + + fun beginFactoryOutage(failures: Int) { + factoryFailuresRemaining = failures + trackingRecovery = true + recovered = CompletableDeferred() + readerCalls = 0 + } + + fun beginCollectionOutage( + failures: Int, + emitBeforeFirstFailure: Boolean, + ) { + collectionFailuresRemaining = failures + emitBeforeFirstCollectionFailure = emitBeforeFirstFailure + trackingRecovery = true + recovered = CompletableDeferred() + readerCalls = 0 + } + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + if (factoryFailuresRemaining > 0) { + factoryFailuresRemaining -= 1 + throw IllegalStateException("reader factory failed") + } + val shouldFail = collectionFailuresRemaining > 0 + val emitFirst = emitBeforeFirstCollectionFailure && collectionFailuresRemaining == 3 + if (shouldFail) collectionFailuresRemaining -= 1 + if (!shouldFail && trackingRecovery) { + recovered.complete(Unit) + trackingRecovery = false + } + return flow { + if (emitFirst) emit(rows.value) + if (shouldFail) throw IllegalStateException("reader collection failed") + rows.collect { emit(it) } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.value = value + } + + override suspend fun delete(key: TestKey) { + rows.value = null + } + } + + private class GatedWriteSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow("seed") + private var readerCalls = 0 + val liveReaderStarted = CompletableDeferred() + val writeStarted = CompletableDeferred() + val releaseWrite = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + if (readerCalls >= 2) liveReaderStarted.complete(Unit) + return flow { + rows.collect { row -> + emit(row) + } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.value = value + writeStarted.complete(Unit) + releaseWrite.await() + rows.value = value + } + + override suspend fun delete(key: TestKey) { + rows.value = null + } + + fun publishExternal(value: String) { + rows.value = value + } + } + + private class GatedFailingWriteSourceOfTruth( + private val failure: Throwable, + ) : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow(null) + val writeStarted = CompletableDeferred() + val releaseWrite = CompletableDeferred() + var deleteCalls: Int = 0 + private set + val current: String? + get() = rows.value + + override fun reader(key: TestKey): Flow = rows + + override suspend fun write( + key: TestKey, + value: String, + ) { + writeStarted.complete(Unit) + releaseWrite.await() + throw failure + } + + override suspend fun delete(key: TestKey) { + deleteCalls += 1 + rows.value = null + } + + fun publishExternal(value: String) { + rows.value = value + } + } + + private class AdapterFailureSourceOfTruth( + private var readerFailure: Throwable? = null, + private var writeFailure: Throwable? = null, + var deleteFailure: Throwable? = null, + ) : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow(null) + var deleteCalls: Int = 0 + private set + val current: String? + get() = rows.value + + override fun reader(key: TestKey): Flow { + readerFailure?.let { throw it } + return rows + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + writeFailure?.let { throw it } + rows.value = value + } + + override suspend fun delete(key: TestKey) { + deleteCalls += 1 + deleteFailure?.let { throw it } + rows.value = null + } + + fun recoverReaderWith(value: String) { + rows.value = value + readerFailure = null + } + } +} + +// Turbine's 3s default would nest inside the 25s shadow. Raising the Turbine deadline above +// the shadow makes runTest the only effective timeout. +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds // strictly > TEST_TIMEOUT: the shadow must fire first + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/MaintenanceTestFakes.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/MaintenanceTestFakes.kt new file mode 100644 index 000000000..a854bf363 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/MaintenanceTestFakes.kt @@ -0,0 +1,133 @@ +package org.mobilenativefoundation.store6.core + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext +import org.mobilenativefoundation.store6.core.internal.InMemoryBookkeeper +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth + +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +internal class RecordingBookkeeper( + private val delegate: Bookkeeper = InMemoryBookkeeper(), + private val events: MutableList = mutableListOf(), + var markStaleFailure: Throwable? = null, + var advanceWatermarkFailure: Throwable? = null, + var forgetFailure: Throwable? = null, + var forgetNamespaceFailure: Throwable? = null, + var forgetAllFailure: Throwable? = null, +) : Bookkeeper by delegate { + val log: List get() = events + var markEntered: CompletableDeferred? = null + var releaseMark: CompletableDeferred? = null + var successEntered: CompletableDeferred? = null + var releaseSuccess: CompletableDeferred? = null + + override suspend fun recordSuccess( + key: StoreKey, + meta: StoreMeta, + ) { + successEntered?.complete(Unit) + releaseSuccess?.await() + events += "recordSuccess:${key.namespace.value}/${key.canonicalId()}" + delegate.recordSuccess(key, meta) + } + + override suspend fun status(key: StoreKey) = + delegate.status(key).also { + events += "status:${key.namespace.value}/${key.canonicalId()}" + } + + override suspend fun markStale(key: StoreKey) { + markEntered?.complete(Unit) + releaseMark?.await() + events += "markStale:${key.namespace.value}/${key.canonicalId()}" + markStaleFailure?.let { throw it } + delegate.markStale(key) + } + + override suspend fun advanceStaleWatermark(namespace: StoreNamespace) { + events += "advanceStaleWatermark:${namespace.value}" + advanceWatermarkFailure?.let { throw it } + delegate.advanceStaleWatermark(namespace) + } + + override suspend fun advanceGlobalStaleWatermark() { + events += "advanceGlobalStaleWatermark" + advanceWatermarkFailure?.let { throw it } + delegate.advanceGlobalStaleWatermark() + } + + override suspend fun forget(key: StoreKey) { + events += "forget:${key.namespace.value}/${key.canonicalId()}" + forgetFailure?.let { throw it } + delegate.forget(key) + } + + override suspend fun forgetNamespace(namespace: StoreNamespace) { + events += "forgetNamespace:${namespace.value}" + forgetNamespaceFailure?.let { throw it } + delegate.forgetNamespace(namespace) + } + + override suspend fun forgetAll() { + events += "forgetAll" + forgetAllFailure?.let { throw it } + delegate.forgetAll() + } +} + +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +internal open class RecordingSourceOfTruth( + protected val delegate: SourceOfTruth, + private val events: MutableList = mutableListOf(), + var deleteFailure: Throwable? = null, + var deleteNamespaceFailure: Throwable? = null, + var deleteAllFailure: Throwable? = null, +) : SourceOfTruth by delegate { + val log: List get() = events + + override suspend fun delete(key: K) { + deleteFailure?.let { throw it } + events += "delete:${key.namespace.value}/${key.canonicalId()}" + delegate.delete(key) + } + + override suspend fun deleteNamespace(namespace: StoreNamespace) { + deleteNamespaceFailure?.let { throw it } + events += "deleteNamespace:${namespace.value}" + delegate.deleteNamespace(namespace) + } + + override suspend fun deleteAll() { + deleteAllFailure?.let { throw it } + events += "deleteAll" + delegate.deleteAll() + } +} + +/** Holds a bulk delete after it is durable, including if the caller is cancelled. */ +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +internal class PostDeleteGateSourceOfTruth( + delegate: SourceOfTruth, +) : RecordingSourceOfTruth(delegate) { + val namespaceDeleted = CompletableDeferred() + val allDeleted = CompletableDeferred() + val releaseDelete = CompletableDeferred() + + override suspend fun deleteNamespace(namespace: StoreNamespace) { + super.deleteNamespace(namespace) + withContext(NonCancellable) { + namespaceDeleted.complete(Unit) + releaseDelete.await() + } + } + + override suspend fun deleteAll() { + super.deleteAll() + withContext(NonCancellable) { + allDeleted.complete(Unit) + releaseDelete.await() + } + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/NamespacedTestKey.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/NamespacedTestKey.kt new file mode 100644 index 000000000..e3c9651cb --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/NamespacedTestKey.kt @@ -0,0 +1,10 @@ +package org.mobilenativefoundation.store6.core + +class NamespacedTestKey( + ns: String, + private val id: String, +) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace(ns) + + override fun canonicalId(): String = id +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/OverlayConformanceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/OverlayConformanceTest.kt new file mode 100644 index 000000000..4fc51b95e --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/OverlayConformanceTest.kt @@ -0,0 +1,232 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.ReceiveTurbine +import app.cash.turbine.test +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.seam.Overlay +import org.mobilenativefoundation.store6.core.seam.runtime +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +@OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) +class OverlayConformanceTest { + private class TestOverlay( + var transform: (String?) -> String?, + val signals: MutableSharedFlow = MutableSharedFlow(replay = 1), + ) : Overlay { + // Transform mutation precedes emit, and emit -> collect supplies ordering. Replay tolerates + // the projection writer's subscription racing the first signal. + override fun apply( + key: TestKey, + base: String?, + ): String? = transform(base) + + override val changes: Flow + get() = signals + } + + private suspend fun ReceiveTurbine>.awaitDataValue( + expected: String, + ): StoreResult.Data { + while (true) { + val item = awaitItem() + if (item is StoreResult.Data && item.value == expected) return item + } + } + + private suspend fun recordUntil( + store: Store, + key: TestKey, + script: suspend () -> Unit, + terminal: String, + ): List { + val recorded = mutableListOf() + var scriptRan = false + store.stream(key).test { + while (true) { + when (val item = awaitItem()) { + is StoreResult.Loading -> recorded += "loading" + is StoreResult.Data -> { + recorded += + "data(${item.value},${item.origin},stale=${item.isStale})" + if (item.value == terminal) { + cancelAndIgnoreRemainingEvents() + return@test + } + if (!scriptRan) { + scriptRan = true + script() + } + } + is StoreResult.Revalidated -> recorded += "revalidated" + is StoreResult.Error -> recorded += "error" + } + } + } + return recorded + } + + @Test + fun identityDefault_emissionSequenceUnchanged() = runTest { + var plainFetches = 0 + var projectedFetches = 0 + val plain = store { fetcher { "v${++plainFetches}" } } + val passThrough = store { + fetcher { "v${++projectedFetches}" } + overlay( + object : Overlay { + override fun apply( + key: TestKey, + base: String?, + ): String? = base + + override val changes: Flow = emptyFlow() + }, + ) + } + val key = TestKey("1") + + try { + val plainSequence = + recordUntil( + store = plain, + key = key, + script = { plain.invalidate(key) }, + terminal = "v2", + ) + val projectedSequence = + recordUntil( + store = passThrough, + key = key, + script = { passThrough.invalidate(key) }, + terminal = "v2", + ) + + assertEquals(plainSequence, projectedSequence) + } finally { + plain.close() + passThrough.close() + } + } + + @Test + fun modifyingOverlay_stampsOverlayOrigin() = runTest { + val store = store { + fetcher { "v" } + overlay(TestOverlay({ it?.uppercase() })) + } + + try { + store.stream(TestKey("1")).test { + val data = awaitDataValue("V") + assertEquals(Origin.OVERLAY, data.origin) + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun changeSignal_reprojectsForActiveCollectors() = runTest { + val overlay = TestOverlay({ it }) + val store = store { + fetcher { "v" } + overlay(overlay) + } + + try { + store.stream(TestKey("1")).test { + awaitDataValue("v") + overlay.transform = { base -> base + "+pending" } + overlay.signals.emit(TestKey("1")) + val projected = awaitDataValue("v+pending") + assertEquals(Origin.OVERLAY, projected.origin) + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun absentBase_overlayProjectionServesCreate() = runTest { + val store = store { + fetcher { awaitCancellation() } + overlay(TestOverlay({ it ?: "pending-create" })) + } + + try { + store.stream(TestKey("1")).test { + val data = awaitDataValue("pending-create") + assertEquals(Origin.OVERLAY, data.origin) + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun nullProjection_overResident_emitsAbsentTransition() = runTest { + val overlay = TestOverlay({ it }) + val store = store { + fetcher { "v" } + overlay(overlay) + } + + try { + store.stream(TestKey("1")).test { + awaitDataValue("v") + overlay.transform = { null } + overlay.signals.emit(TestKey("1")) + assertIs(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun retireAfterConfirmedCommit_neverReemitsOldBase() = runTest { + val overlay = TestOverlay({ base -> base?.plus("+op") }) + val store = store { + fetcher { "v1" } + overlay(overlay) + } + val key = TestKey("1") + + try { + store.stream(key).test { + awaitDataValue("v1+op") + cancelAndIgnoreRemainingEvents() + } + store.runtime()!!.writeHandle.apply(key, "v2") + overlay.transform = { it } + overlay.signals.emit(TestKey("1")) + + store.stream(key).test { + while (true) { + val item = awaitItem() + if (item is StoreResult.Data) { + assertTrue(item.value != "v1" && item.value != "v1+op") + if (item.value == "v2") { + assertTrue(item.origin == Origin.SOT || item.origin == Origin.MEMORY) + cancelAndIgnoreRemainingEvents() + break + } + } + } + } + } finally { + store.close() + } + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/PublicSurfaceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/PublicSurfaceTest.kt new file mode 100644 index 000000000..000eefafb --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/PublicSurfaceTest.kt @@ -0,0 +1,85 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.test +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes + +class PublicSurfaceTest { + + /** Detector fixture: an unstable canonicalId fails fast and names the fix. */ + private class UnstableKey : StoreKey { + private var counter = 0 + override val namespace: StoreNamespace = StoreNamespace("unstable") + override fun canonicalId(): String = "id-${counter++}" + } + + @Test + fun unstableCanonicalId_failsFastNamingTheFix() = runTest { + val store = store { fetcher { "v" } } + val failure = assertFailsWith { store.get(UnstableKey()) } + assertTrue(failure.message!!.contains("canonicalId")) + assertTrue(failure.message!!.contains("unstable")) // names the namespace + assertTrue(failure.message!!.contains("stable")) // names the fix + store.close() + } + + /** Frozen-surface compile lock: every result/error variant constructible via internal ctors. */ + @Test + fun frozenResultAndErrorVariants_constructibleWithFullPayloads() { + val data = StoreResult.Data( + value = "v", + origin = Origin.MEMORY, + age = Duration.ZERO, + isStale = false, + refreshing = false, + ) + assertEquals("v", data.value) + + StoreResult.Loading() + assertEquals(Duration.ZERO, StoreResult.Revalidated(age = Duration.ZERO).age) + + val errors: List = listOf( + StoreError.Fetch(message = "m", cause = null), + StoreError.Persistence(message = "m", cause = null), + StoreError.Conversion(message = "m", cause = null), + StoreError.FreshnessUnsatisfiable(message = "m"), + StoreError.Conflict(serverMeta = null, message = "m"), + StoreError.Missing(key = TestKey("1"), message = "m"), + ) + val wrapped = StoreResult.Error(error = errors.first(), servedStale = false) + assertIs(wrapped.error) + + // messageOf is exhaustive over the frozen set. + errors.forEach { error -> + assertEquals("m", StoreException(error).message) + } + } + + /** Freshness values are accepted everywhere and apply their documented postures. */ + @Test + fun allFreshnessValues_acceptedWithHonestPostures() = runTest { + val store = store { fetcher { "v" } } + val policies = listOf( + Freshness.CachedOrFetch, + Freshness.MaxAge(notOlderThan = 5.minutes), // public constructor + Freshness.MustBeFresh, + Freshness.StaleIfError, + Freshness.LocalOnly, + ) + for (policy in policies) { + assertEquals("v", store.get(TestKey("k"), policy)) + } + store.stream(TestKey("k"), Freshness.MustBeFresh).test { + assertIs(awaitItem()) + assertIs>(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + store.close() + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/ReaderGenerationRecoveryTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/ReaderGenerationRecoveryTest.kt new file mode 100644 index 000000000..40d2749af --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/ReaderGenerationRecoveryTest.kt @@ -0,0 +1,142 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.test +import app.cash.turbine.testIn +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest as coroutineRunTest +import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield +import org.mobilenativefoundation.store6.core.internal.RotatingSlotSourceOfTruth +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalStoreApi::class, ExperimentalCoroutinesApi::class) +class ReaderGenerationRecoveryTest { + private val key = TestKey("rotating-slot") + + @Test + fun readerGen_clear_reconnectsRotatingSlotReader() = runTest { + val sourceOfTruth = RotatingSlotSourceOfTruth() + var fetchCalls = 0 + val store = store { + persistence(sourceOfTruth) + fetcher { + when (++fetchCalls) { + 1 -> "v1" + 2 -> { + sourceOfTruth.awaitCurrentSlotReaderDelivery(key) + "v2" + } + else -> error("unexpected fetch call $fetchCalls") + } + } + } + + try { + store.stream(key).test { + while (true) { + val item = awaitItem() + if (item is StoreResult.Data && item.value == "v1") break + } + runCurrent() + val subscriptionsBeforeClear = sourceOfTruth.subscriptionCount(key) + + store.clear(key) + awaitSubscriptionAfter(sourceOfTruth, subscriptionsBeforeClear) + while (true) { + val item = awaitItem() + if (item is StoreResult.Data && item.value == "v2") break + } + assertEquals(2, fetchCalls) + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun readerGen_serverDelete_reconnectsRotatingSlotReader() = runTest { + val sourceOfTruth = RotatingSlotSourceOfTruth() + var fetchCalls = 0 + val store = store { + persistence(sourceOfTruth) + fetcherOfResult { + when (++fetchCalls) { + 1 -> FetcherResult.Success("v1") + 2 -> FetcherResult.Deleted + else -> error("unexpected fetch call $fetchCalls") + } + } + } + + try { + assertEquals("v1", store.get(key)) + app.cash.turbine.turbineScope { + val observer = store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("v1", assertIs>(observer.awaitItem()).value) + runCurrent() + val subscriptionsBeforeDelete = sourceOfTruth.subscriptionCount(key) + + val deletion = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { store.get(key, Freshness.MustBeFresh) } + } + runCurrent() + val failure = assertIs(deletion.await().exceptionOrNull()) + assertIs(failure.error) + awaitSubscriptionAfter(sourceOfTruth, subscriptionsBeforeDelete) + + sourceOfTruth.write(key, "v2") + runCurrent() + while (true) { + val item = observer.awaitItem() + if (item is StoreResult.Data && item.value == "v2") break + } + assertEquals(2, fetchCalls) + observer.cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + private suspend fun awaitSubscriptionAfter( + sourceOfTruth: RotatingSlotSourceOfTruth, + previousCount: Int, + ) { + // Preserve the real-time Default-dispatch hop and let the suite-level runTest bound own + // cancellation. + withContext(Dispatchers.Default) { + while (sourceOfTruth.subscriptionCount(key) <= previousCount) { + yield() + } + } + assertTrue( + sourceOfTruth.subscriptionCount(key) > previousCount, + "readerGen must attach to the rotated durable slot", + ) + } +} + +// Turbine's 3s default would nest inside the 25s shadow. Raising the Turbine deadline above +// the shadow makes runTest the only effective timeout. +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds // strictly > TEST_TIMEOUT: the shadow must fire first + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/ReaderHopSourceOfTruth.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/ReaderHopSourceOfTruth.kt new file mode 100644 index 000000000..61b263c9e --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/ReaderHopSourceOfTruth.kt @@ -0,0 +1,51 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, +) + +package org.mobilenativefoundation.store6.core + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.yield +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth + +/** + * Adversarial test decorator that adds a real-dispatcher hop and yield to widen the + * queued-reader-frame race. + * + * SQLDelight's `readContext` switches before row capture, so its adversarial lane uses an + * equivalent post-capture decorator instead of treating `Default` versus `EmptyCoroutineContext` + * as this seam. This decorator is test-only and unpublished; mutations are left untouched. + */ +internal class ReaderHopSourceOfTruth( + private val delegate: SourceOfTruth, +) : SourceOfTruth { + override fun reader(key: K): Flow = + delegate.reader(key) + .map { + yield() + it + }.flowOn(Dispatchers.Default) + + override suspend fun write( + key: K, + value: V, + ) { + delegate.write(key, value) + } + + override suspend fun delete(key: K) { + delegate.delete(key) + } + + override suspend fun deleteNamespace(namespace: StoreNamespace) { + delegate.deleteNamespace(namespace) + } + + override suspend fun deleteAll() { + delegate.deleteAll() + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SchedulerPerturbationRuns.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SchedulerPerturbationRuns.kt new file mode 100644 index 000000000..e89c9bc97 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SchedulerPerturbationRuns.kt @@ -0,0 +1,29 @@ +@file:OptIn(ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.core + +import org.mobilenativefoundation.store6.core.internal.InMemorySourceOfTruth + +class StoreInvalidationConformanceUnderReaderHopTest : StoreInvalidationConformanceTest() { + override fun installSot(builder: StoreBuilder) { + builder.persistence( + trackedSot(ReaderHopSourceOfTruth(InMemorySourceOfTruth())), + ) + } +} + +class EmissionSequenceConformanceUnderReaderHopTest : EmissionSequenceConformanceTest() { + override fun installSot(builder: StoreBuilder) { + builder.persistence( + trackedSot(ReaderHopSourceOfTruth(InMemorySourceOfTruth())), + ) + } +} + +class FreshnessPolicyConformanceUnderReaderHopTest : FreshnessPolicyConformanceTest() { + override fun installSot(builder: StoreBuilder) { + builder.persistence( + trackedSot(ReaderHopSourceOfTruth(InMemorySourceOfTruth())), + ) + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SeamFetcherTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SeamFetcherTest.kt new file mode 100644 index 000000000..6dbc85029 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SeamFetcherTest.kt @@ -0,0 +1,40 @@ +package org.mobilenativefoundation.store6.core + +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.seam.Fetcher +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) +class SeamFetcherTest { + @Test + fun seamFetcher_receivesEtagOnConditionalPlan() = runTest { + // etags is mutated on the fetch coroutine (Dispatchers.Default) and read here only after the + // corresponding get() returned — the FetchTicket completion edge orders every mutation + // before the read (the T3 placement pin formalizes this happens-before). + val etags = mutableListOf() + val store = + store { + fetcher( + object : Fetcher { + override suspend fun fetch( + key: TestKey, + etag: String?, + ): FetcherResult { + etags += etag + return FetcherResult.Success( + "v${etags.size}", + etag = "tag-${etags.size}", + ) + } + }, + ) + } + + assertEquals("v1", store.get(TestKey("1"))) + assertEquals("v2", store.get(TestKey("1"), Freshness.MustBeFresh)) + assertEquals(listOf(null, "tag-1"), etags) + store.close() + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SingleFlightConformanceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SingleFlightConformanceTest.kt new file mode 100644 index 000000000..1775058ed --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SingleFlightConformanceTest.kt @@ -0,0 +1,108 @@ +package org.mobilenativefoundation.store6.core + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +open class SingleFlightConformanceTest : SourceOfTruthSubstitutionTest() { + @Test + fun ac2_fiftyGettersAndFiftyCollectorsShareOneFetch() = runTest { + var calls = 0 + val started = CompletableDeferred() + val gate = CompletableDeferred() + val store = testStore { + fetcher { + calls++ + started.complete(Unit) + gate.await() + "v" + } + } + val key = TestKey("1") + + try { + val getters = + List(50) { + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.get(key) + } + } + val collectorRegistered = List(50) { CompletableDeferred() } + val collectors = + List(50) { index -> + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + val item = + store + .stream(key) + .onEach { result -> + if (result is StoreResult.Loading) { + collectorRegistered[index].complete(Unit) + } + } + .first { it is StoreResult.Data<*> } + assertIs>(item).value + } + } + + started.await() + // A channelFlow producer may start after the fetcher's signal. Loading proves each + // stream demand joined the still-gated ticket before the fetch is allowed to settle. + collectorRegistered.awaitAll() + assertEquals(1, calls) + gate.complete(Unit) + + assertTrue(getters.awaitAll().all { it == "v" }) + assertTrue(collectors.awaitAll().all { it == "v" }) + assertEquals(1, calls) + } finally { + store.close() + } + } + + @Test + fun cancelledWaiterDoesNotCancelSharedFetch() = runTest { + var calls = 0 + val started = CompletableDeferred() + val gate = CompletableDeferred() + val store = testStore { + fetcher { + calls++ + started.complete(Unit) + gate.await() + "v" + } + } + val key = TestKey("1") + + try { + val cancelled = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.get(key) + } + started.await() + cancelled.cancelAndJoin() + assertTrue(cancelled.isCancelled) + + val later = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.get(key) + } + gate.complete(Unit) + + assertEquals("v", later.await()) + assertEquals("v", store.get(key)) + assertEquals(1, calls) + } finally { + store.close() + } + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthAdditionalRaceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthAdditionalRaceTest.kt new file mode 100644 index 000000000..d420db16b --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthAdditionalRaceTest.kt @@ -0,0 +1,510 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.testIn +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.internal.DefaultFreshnessValidator +import org.mobilenativefoundation.store6.core.internal.InMemoryBookkeeper +import org.mobilenativefoundation.store6.core.internal.KeyEngine +import org.mobilenativefoundation.store6.core.internal.KeyId +import org.mobilenativefoundation.store6.core.internal.LambdaFetcher +import org.mobilenativefoundation.store6.core.internal.READER_PIPELINE_GRACE_MILLIS +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.minutes + +@OptIn(ExperimentalStoreApi::class, ExperimentalCoroutinesApi::class) +class SourceOfTruthAdditionalRaceTest { + private val key = TestKey("additional-race") + + @Test + fun externalWriteReturned_whileGraceReplayAbsent_streamServesDurableBeforeFetch() = runTest { + val sourceOfTruth = GraceReplaySourceOfTruth(initial = null) + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + var fetchCalls = 0 + val engine = + newEngine(sourceOfTruth) { + fetchCalls += 1 + fetchStarted.complete(Unit) + releaseFetch.await() + "fetched" + } + + try { + app.cash.turbine.turbineScope { + val first = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertIs( + assertIs(first.awaitItem()).error, + ) + sourceOfTruth.liveReaderStarted.await() + runCurrent() + first.cancelAndIgnoreRemainingEvents() + advanceToLastReaderGraceMillisecond() + + sourceOfTruth.gateNextLiveDelivery() + sourceOfTruth.write(key, "durable") + sourceOfTruth.liveDeliveryBlocked.await() + + val second = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + val durable = assertIs>(second.awaitItem()) + assertEquals("durable", durable.value) + assertEquals(Origin.SOT, durable.origin) + assertTrue(durable.isStale) + assertTrue(durable.refreshing) + fetchStarted.await() + assertEquals(1, fetchCalls) + runCurrent() + + sourceOfTruth.releaseLiveDelivery.complete(Unit) + runCurrent() + releaseFetch.complete(Unit) + while (true) { + val item = second.awaitItem() + if (item is StoreResult.Data && item.value == "fetched") break + } + second.cancelAndIgnoreRemainingEvents() + } + } finally { + sourceOfTruth.releaseLiveDelivery.complete(Unit) + releaseFetch.complete(Unit) + } + } + + @Test + fun externalDeleteReturned_whileGraceHasOldMemory_streamConvergesMemoryThenLoading() = runTest { + val sourceOfTruth = GraceReplaySourceOfTruth(initial = "seed") + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + var fetchCalls = 0 + val engine = + newEngine(sourceOfTruth) { + fetchCalls += 1 + fetchStarted.complete(Unit) + releaseFetch.await() + "fresh" + } + + try { + app.cash.turbine.turbineScope { + val first = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(first.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + runCurrent() + first.cancelAndIgnoreRemainingEvents() + advanceToLastReaderGraceMillisecond() + + sourceOfTruth.gateNextLiveDelivery() + sourceOfTruth.delete(key) + sourceOfTruth.liveDeliveryBlocked.await() + + val second = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + val memory = assertIs>(second.awaitItem()) + assertEquals("seed", memory.value) + assertEquals(Origin.MEMORY, memory.origin) + fetchStarted.await() + runCurrent() + + sourceOfTruth.releaseLiveDelivery.complete(Unit) + assertIs(second.awaitItem()) + releaseFetch.complete(Unit) + while (true) { + val item = second.awaitItem() + if (item is StoreResult.Data && item.value == "fresh") break + } + assertEquals(1, fetchCalls) + second.cancelAndIgnoreRemainingEvents() + } + } finally { + sourceOfTruth.releaseLiveDelivery.complete(Unit) + releaseFetch.complete(Unit) + } + } + + @Test + fun sameGenerationRowReplay_afterResidenceAdvances_neverRegresses() = runTest { + val sourceOfTruth = GraceReplaySourceOfTruth(initial = "old") + val engine = newEngine(sourceOfTruth) { "fresh" } + + try { + app.cash.turbine.turbineScope { + val first = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("old", assertIs>(first.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + runCurrent() + first.cancelAndIgnoreRemainingEvents() + advanceToLastReaderGraceMillisecond() + + sourceOfTruth.gateNextLiveDelivery() + val refresh = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.get(Freshness.MustBeFresh) + } + sourceOfTruth.liveDeliveryBlocked.await() + assertEquals("fresh", refresh.await()) + + val second = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("fresh", assertIs>(second.awaitItem()).value) + runCurrent() + second.expectNoEvents() + + sourceOfTruth.releaseLiveDelivery.complete(Unit) + runCurrent() + sourceOfTruth.publishExternal("authoritative-row") + val authoritative = assertIs>(second.awaitItem()) + assertEquals("authoritative-row", authoritative.value) + assertEquals(Origin.SOT, authoritative.origin) + second.cancelAndIgnoreRemainingEvents() + } + } finally { + sourceOfTruth.releaseLiveDelivery.complete(Unit) + } + } + + @Test + fun sameGenerationAbsentReplay_afterResidenceAdvances_neverEmitsFalseLoading() = runTest { + val sourceOfTruth = GraceReplaySourceOfTruth(initial = null) + val engine = newEngine(sourceOfTruth) { "fresh" } + + try { + app.cash.turbine.turbineScope { + val first = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertIs( + assertIs(first.awaitItem()).error, + ) + sourceOfTruth.liveReaderStarted.await() + runCurrent() + first.cancelAndIgnoreRemainingEvents() + advanceToLastReaderGraceMillisecond() + + sourceOfTruth.gateNextLiveDelivery() + val refresh = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.get(Freshness.MustBeFresh) + } + sourceOfTruth.liveDeliveryBlocked.await() + assertEquals("fresh", refresh.await()) + + val second = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("fresh", assertIs>(second.awaitItem()).value) + runCurrent() + second.expectNoEvents() + + sourceOfTruth.releaseLiveDelivery.complete(Unit) + runCurrent() + sourceOfTruth.publishExternal("authoritative-after-absent") + val authoritative = assertIs>(second.awaitItem()) + assertEquals("authoritative-after-absent", authoritative.value) + assertEquals(Origin.SOT, authoritative.origin) + second.cancelAndIgnoreRemainingEvents() + } + } finally { + sourceOfTruth.releaseLiveDelivery.complete(Unit) + } + } + + @Test + fun queuedAbsent_consumesTag_andLaterEqualRowDoesNotInheritFetcher() = runTest { + val sourceOfTruth = QueuedAbsentThenWriterSourceOfTruth() + val store = store { + persistence(sourceOfTruth) + fetcher { "candidate" } + } + + try { + app.cash.turbine.turbineScope { + val observer = store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + runCurrent() + + val fetched = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.get(key, Freshness.MustBeFresh) + } + sourceOfTruth.queuedAbsentEmitted.await() + assertIs(observer.awaitItem()) + assertIs( + assertIs(observer.awaitItem()).error, + ) + + sourceOfTruth.releaseWriterEcho.complete(Unit) + assertEquals("candidate", fetched.await()) + val writer = assertIs>(observer.awaitItem()) + assertEquals("candidate", writer.value) + assertEquals(Origin.FETCHER, writer.origin) + + sourceOfTruth.publishExternal(null) + assertIs(observer.awaitItem()) + assertIs( + assertIs(observer.awaitItem()).error, + ) + sourceOfTruth.publishExternal("candidate") + val external = assertIs>(observer.awaitItem()) + assertEquals("candidate", external.value) + assertEquals(Origin.SOT, external.origin) + observer.cancelAndIgnoreRemainingEvents() + } + } finally { + sourceOfTruth.releaseWriterEcho.complete(Unit) + store.close() + } + } + + @Test + fun mustBeFresh_externalWithheldRow_transitionsDataToLoadingBeforeRefresh() = runTest { + val sourceOfTruth = ReactiveSourceOfTruth(initial = null) + val secondFetchStarted = CompletableDeferred() + val releaseSecondFetch = CompletableDeferred() + var fetchCalls = 0 + val store = store { + persistence(sourceOfTruth) + fetcher { + when (++fetchCalls) { + 1 -> "v1" + 2 -> { + secondFetchStarted.complete(Unit) + releaseSecondFetch.await() + "v2" + } + + else -> error("unexpected fetch call $fetchCalls") + } + } + } + + try { + app.cash.turbine.turbineScope { + val collector = + store.stream(key, Freshness.MustBeFresh).testIn(backgroundScope) + assertIs(collector.awaitItem()) + assertEquals("v1", assertIs>(collector.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + runCurrent() + + sourceOfTruth.publishExternal("external") + secondFetchStarted.await() + assertIs(collector.awaitItem()) + + releaseSecondFetch.complete(Unit) + val refreshed = assertIs>(collector.awaitItem()) + assertEquals("v2", refreshed.value) + assertEquals(Origin.FETCHER, refreshed.origin) + assertEquals(2, fetchCalls) + collector.cancelAndIgnoreRemainingEvents() + } + } finally { + releaseSecondFetch.complete(Unit) + store.close() + } + } + + @Test + fun maxAge_externalWithheldRow_transitionsDataToLoadingBeforeRefresh() = runTest { + val sourceOfTruth = ReactiveSourceOfTruth(initial = null) + val secondFetchStarted = CompletableDeferred() + val releaseSecondFetch = CompletableDeferred() + var fetchCalls = 0 + val store = store { + persistence(sourceOfTruth) + fetcher { + when (++fetchCalls) { + 1 -> "v1" + 2 -> { + secondFetchStarted.complete(Unit) + releaseSecondFetch.await() + "v2" + } + + else -> error("unexpected fetch call $fetchCalls") + } + } + } + + try { + app.cash.turbine.turbineScope { + val collector = + store.stream(key, Freshness.MaxAge(notOlderThan = 5.minutes)) + .testIn(backgroundScope) + assertIs(collector.awaitItem()) + assertEquals("v1", assertIs>(collector.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + runCurrent() + + sourceOfTruth.publishExternal("external") + secondFetchStarted.await() + assertIs(collector.awaitItem()) + + releaseSecondFetch.complete(Unit) + val refreshed = assertIs>(collector.awaitItem()) + assertEquals("v2", refreshed.value) + assertEquals(Origin.FETCHER, refreshed.origin) + assertEquals(2, fetchCalls) + collector.cancelAndIgnoreRemainingEvents() + } + } finally { + releaseSecondFetch.complete(Unit) + store.close() + } + } + + /** Keeps the shared reader alive through grace while allowing direct one-shot RYW probes. */ + private class GraceReplaySourceOfTruth( + initial: String?, + ) : SingleRowTestSourceOfTruth { + private val liveRows = MutableSharedFlow(extraBufferCapacity = 8) + private var current: String? = initial + private var gateNextLive = false + + val liveReaderStarted = CompletableDeferred() + var liveDeliveryBlocked = CompletableDeferred() + private set + var releaseLiveDelivery = CompletableDeferred() + private set + + fun gateNextLiveDelivery() { + check(liveReaderStarted.isCompleted) { + "the shared reader must be active before gating delivery" + } + check(!gateNextLive) { "a live delivery is already gated" } + gateNextLive = true + liveDeliveryBlocked = CompletableDeferred() + releaseLiveDelivery = CompletableDeferred() + } + + override fun reader(key: TestKey): Flow = + flow { + // `first()` aborts during this emit, so only the long-lived pipeline reaches the + // live tail and marks itself ready. Every full collection still remains live. + emit(current) + liveReaderStarted.complete(Unit) + liveRows.collect { row -> + if (gateNextLive) { + gateNextLive = false + liveDeliveryBlocked.complete(Unit) + releaseLiveDelivery.await() + } + emit(row) + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + current = value + liveRows.emit(value) + } + + override suspend fun delete(key: TestKey) { + current = null + liveRows.emit(null) + } + + suspend fun publishExternal(value: String) { + current = value + liveRows.emit(value) + } + } + + private fun TestScope.newEngine( + sourceOfTruth: SourceOfTruth, + fetcher: suspend () -> String, + ): KeyEngine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = LambdaFetcher { fetcher() }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + ) + + /** Arms the 100ms stop timer, then stays one virtual millisecond inside its grace window. */ + private fun TestScope.advanceToLastReaderGraceMillisecond() { + runCurrent() + advanceTimeBy(READER_PIPELINE_GRACE_MILLIS - 1L) + } + + private class QueuedAbsentThenWriterSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableSharedFlow(replay = 1) + private var readerCalls = 0 + val liveReaderStarted = CompletableDeferred() + val queuedAbsentEmitted = CompletableDeferred() + val releaseWriterEcho = CompletableDeferred() + + init { + check(rows.tryEmit("seed")) + } + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + if (readerCalls >= 2) liveReaderStarted.complete(Unit) + return rows + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.emit(null) + queuedAbsentEmitted.complete(Unit) + releaseWriterEcho.await() + rows.emit(value) + } + + override suspend fun delete(key: TestKey) { + rows.emit(null) + } + + suspend fun publishExternal(value: String?) { + rows.emit(value) + } + } + + private class ReactiveSourceOfTruth( + initial: String?, + ) : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow(initial) + private var readerCalls = 0 + val liveReaderStarted = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + if (readerCalls >= 2) liveReaderStarted.complete(Unit) + return rows + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.value = value + } + + override suspend fun delete(key: TestKey) { + rows.value = null + } + + fun publishExternal(value: String) { + rows.value = value + } + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthBindingConformanceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthBindingConformanceTest.kt new file mode 100644 index 000000000..2a7cd0759 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthBindingConformanceTest.kt @@ -0,0 +1,2085 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.ReceiveTurbine +import app.cash.turbine.test +import app.cash.turbine.testIn +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.produceIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest as coroutineRunTest +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.mobilenativefoundation.store6.core.internal.DefaultFreshnessValidator +import org.mobilenativefoundation.store6.core.internal.FetchDisposition +import org.mobilenativefoundation.store6.core.internal.FetchOutcome +import org.mobilenativefoundation.store6.core.internal.FetchSlot +import org.mobilenativefoundation.store6.core.internal.InMemoryBookkeeper +import org.mobilenativefoundation.store6.core.internal.KeyEngine +import org.mobilenativefoundation.store6.core.internal.KeyId +import org.mobilenativefoundation.store6.core.internal.ResultFetcher +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import org.mobilenativefoundation.store6.core.seam.KeyStatus +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.test.fail +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class, ExperimentalCoroutinesApi::class) +class SourceOfTruthBindingConformanceTest { + private val key = TestKey("key") + + @Test + fun hydrationQueuedBeforeClear_cannotResurrectAfterClearCompletes() = runTest { + val sourceOfTruth = GatedHydrationSourceOfTruth() + val store = store { + persistence(sourceOfTruth) + fetcher { error("fetch must not run") } + } + + try { + val hydration = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.get(key, Freshness.LocalOnly) + } + sourceOfTruth.readerStarted.await() + val clear = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.clear(key) + } + runCurrent() + + sourceOfTruth.releaseReader.complete(Unit) + assertEquals("stale", hydration.await()) + clear.await() + + val missing = + assertFailsWith { + store.get(key, Freshness.LocalOnly) + } + assertIs(missing.error) + } finally { + sourceOfTruth.releaseReader.complete(Unit) + store.close() + } + } + + @Test + fun externalReplacementAfterWriteReturn_winsDuringGatedRecordSuccess() = runTest { + val sourceOfTruth = MutableSourceOfTruth(initial = "seed") + val bookkeeper = GateNextSuccessBookkeeper() + val secondFetchStarted = CompletableDeferred() + val releaseSecondFetch = CompletableDeferred() + var fetchCalls = 0 + val store = storeWith(bookkeeper = bookkeeper) { + persistence(sourceOfTruth) + fetcher { + when (val call = ++fetchCalls) { + 1 -> "fetched-1" + 2 -> { + secondFetchStarted.complete(Unit) + releaseSecondFetch.await() + "fetched-2" + } + + else -> error("unexpected fetch call $call") + } + } + } + + try { + assertEquals("seed", store.get(key, Freshness.LocalOnly)) + bookkeeper.gateNextSuccess() + app.cash.turbine.turbineScope { + val collector = store.stream(key).testIn(backgroundScope) + withContext(Dispatchers.Default) { + bookkeeper.successEntered.await() + } + + sourceOfTruth.publishExternal("external") + awaitLocalValue(store, "external") + bookkeeper.releaseSuccess.complete(Unit) + withContext(Dispatchers.Default) { + secondFetchStarted.await() + } + assertEquals("external", store.get(key, Freshness.LocalOnly)) + + releaseSecondFetch.complete(Unit) + awaitLocalValue(store, "fetched-2") + assertEquals(2, fetchCalls) + collector.cancelAndIgnoreRemainingEvents() + } + } finally { + bookkeeper.releaseSuccess.complete(Unit) + releaseSecondFetch.complete(Unit) + store.close() + } + } + + @Test + fun notModified_externalReplacementObservedAfterLaunch_isObsoleteNotFreshened() = runTest { + val sourceOfTruth = MutableSourceOfTruth() + val secondStarted = CompletableDeferred() + val releaseSecond = CompletableDeferred() + var calls = 0 + val store = store { + persistence(sourceOfTruth) + fetcherOfResult { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> { + secondStarted.complete(Unit) + releaseSecond.await() + FetcherResult.NotModified(etag = "e2") + } + + 3 -> FetcherResult.Success("fresh", etag = "e3") + else -> error("unexpected fetch call $calls") + } + } + } + + try { + assertEquals("v1", store.get(key)) + app.cash.turbine.turbineScope { + val observer = store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("v1", assertIs>(observer.awaitItem()).value) + val fresh = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.get(key, Freshness.MustBeFresh) + } + secondStarted.await() + + sourceOfTruth.publishExternal("external") + var external: StoreResult.Data? = null + while (true) { + val item = observer.awaitItem() + if (item is StoreResult.Data && item.value == "external") { + external = item + break + } + } + assertExternalSotStale(assertNotNull(external)) + releaseSecond.complete(Unit) + + assertEquals("fresh", fresh.await()) + var subsequentSuccess: StoreResult.Data? = null + while (subsequentSuccess == null) { + val item = observer.awaitItem() + if (item is StoreResult.Data) { + when (item.value) { + "external" -> assertExternalSotStale(item) + "fresh" -> subsequentSuccess = item + else -> error("unexpected value after external replacement: ${item.value}") + } + } + } + val success = assertNotNull(subsequentSuccess) + assertEquals(Origin.FETCHER, success.origin) + assertFalse(success.isStale) + assertEquals(3, calls) + observer.cancelAndIgnoreRemainingEvents() + } + } finally { + releaseSecond.complete(Unit) + store.close() + } + } + + @Test + fun notModifiedDirectDelivery_revisionMismatchDoesNotEmitReplayedResidence() = runTest { + val sourceOfTruth = ReplayableSourceOfTruth() + val bookkeeper = GateNextSuccessBookkeeper() + var calls = 0 + val store = storeWith(bookkeeper = bookkeeper) { + persistence(sourceOfTruth) + fetcherOfResult { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> FetcherResult.NotModified(etag = "e2") + 3 -> FetcherResult.Success("fresh", etag = "e3") + else -> error("unexpected fetch call $calls") + } + } + } + + try { + assertEquals("v1", store.get(key)) + app.cash.turbine.turbineScope { + val observer = store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("v1", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + sourceOfTruth.replay("sync") + observer.awaitDataValue("sync") + sourceOfTruth.replay("v1") + observer.awaitDataValue("v1") + store.invalidate(key) + bookkeeper.gateNextSuccess() + + store.stream(key).test { + val stale = assertIs>(awaitItem()) + assertEquals("v1", stale.value) + assertTrue(stale.isStale) + withContext(Dispatchers.Default) { + bookkeeper.successEntered.await() + } + sourceOfTruth.replay("v1") + expectNoEvents() + + sourceOfTruth.publishExternal("external") + var observerExternal: StoreResult.Data? = null + while (observerExternal == null) { + val item = observer.awaitItem() + if (item is StoreResult.Data && item.value == "external") { + observerExternal = item + } + } + assertExternalSotStale(assertNotNull(observerExternal)) + bookkeeper.releaseSuccess.complete(Unit) + + var sawFresh = false + while (!sawFresh) { + when (val item = awaitItem()) { + is StoreResult.Data -> { + if (item.value == "external") assertExternalSotStale(item) + assertFalse( + item.value == "v1" && !item.isStale, + "stale 304 revision must not directly re-emit v1 as fresh", + ) + if (item.value == "fresh") { + assertEquals(Origin.FETCHER, item.origin) + assertFalse(item.isStale) + sawFresh = true + } + } + + is StoreResult.Revalidated -> + fail("obsolete 304 must not emit Revalidated") + + else -> Unit + } + } + assertEquals(3, calls) + cancelAndIgnoreRemainingEvents() + } + observer.cancelAndIgnoreRemainingEvents() + } + } finally { + bookkeeper.releaseSuccess.complete(Unit) + store.close() + } + } + + @Test + fun notModifiedDirect_afterMappedSameValueBaseline_emitsRevalidatedExactlyOnce() = runTest { + val sourceOfTruth = ReplayableSourceOfTruth() + var calls = 0 + val store = store { + persistence(sourceOfTruth) + fetcherOfResult { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> FetcherResult.NotModified(etag = "e2") + else -> error("unexpected fetch call $calls") + } + } + } + + try { + assertEquals("v1", store.get(key)) + app.cash.turbine.turbineScope { + val observer = store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("v1", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + sourceOfTruth.replay("sync") + observer.awaitDataValue("sync") + sourceOfTruth.replay("v1") + observer.awaitDataValue("v1") + store.invalidate(key) + + val collector = store.stream(key).testIn(backgroundScope) + assertTrue(assertIs>(collector.awaitItem()).isStale) + assertIs(collector.awaitItem()) + collector.expectNoEvents() + + assertEquals(2, calls) + observer.cancelAndIgnoreRemainingEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun sameValueReplayThenNotModifiedDirect_emitsRevalidatedExactlyOnce() = runTest { + val sourceOfTruth = ReplayableSourceOfTruth() + val bookkeeper = GateNextSuccessBookkeeper() + val secondStarted = CompletableDeferred() + val releaseSecond = CompletableDeferred() + var calls = 0 + val store = storeWith(bookkeeper = bookkeeper) { + persistence(sourceOfTruth) + fetcherOfResult { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> { + secondStarted.complete(Unit) + releaseSecond.await() + FetcherResult.NotModified(etag = "e2") + } + else -> error("unexpected fetch call $calls") + } + } + } + + try { + assertEquals("v1", store.get(key)) + app.cash.turbine.turbineScope { + val observer = store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("v1", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + sourceOfTruth.replay("sync") + observer.awaitDataValue("sync") + sourceOfTruth.replay("v1") + observer.awaitDataValue("v1") + store.invalidate(key) + bookkeeper.gateNextSuccess() + + val collector = store.stream(key).testIn(backgroundScope) + assertTrue(assertIs>(collector.awaitItem()).isStale) + secondStarted.await() + releaseSecond.complete(Unit) + bookkeeper.successEntered.await() + sourceOfTruth.replay("v1") + collector.expectNoEvents() + bookkeeper.releaseSuccess.complete(Unit) + + assertIs(collector.awaitItem()) + collector.expectNoEvents() + assertEquals(2, calls) + observer.cancelAndIgnoreRemainingEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } finally { + releaseSecond.complete(Unit) + bookkeeper.releaseSuccess.complete(Unit) + store.close() + } + } + + @Test + fun lateCollector_exactResidentRevisionEmitsMemoryOnce() = runTest { + val store = store { fetcher { "v1" } } + + try { + assertEquals("v1", store.get(key)) + store.stream(key, Freshness.LocalOnly).test { + val data = assertIs>(awaitItem()) + assertEquals("v1", data.value) + assertEquals(Origin.MEMORY, data.origin) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun externalWriteReturned_whileGraceHasOldMemory_convergesMemoryThenSot() = runTest { + val sourceOfTruth = GraceGateSourceOfTruth() + val store = store { + persistence(sourceOfTruth) + fetcher { error("fetch must not run") } + } + + try { + app.cash.turbine.turbineScope { + val first = store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(first.awaitItem()).value) + withContext(Dispatchers.Default) { + sourceOfTruth.liveReaderStarted.await() + } + first.cancelAndIgnoreRemainingEvents() + + sourceOfTruth.gateExternalEmission() + sourceOfTruth.write(key, "external") + withContext(Dispatchers.Default) { + sourceOfTruth.externalEmissionBlocked.await() + } + + val second = store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + val memory = assertIs>(second.awaitItem()) + assertEquals("seed", memory.value) + assertEquals(Origin.MEMORY, memory.origin) + + sourceOfTruth.releaseExternalEmission.complete(Unit) + val external = assertIs>(second.awaitItem()) + assertEquals("external", external.value) + assertEquals(Origin.SOT, external.origin) + second.cancelAndIgnoreRemainingEvents() + } + } finally { + sourceOfTruth.releaseExternalEmission.complete(Unit) + store.close() + } + } + + @Test + fun fetchFailureAfterReactiveAbsence_reportsServedStaleFalse() = runTest { + val sourceOfTruth = MutableSourceOfTruth() + val secondStarted = CompletableDeferred() + val releaseSecond = CompletableDeferred() + val boom = IllegalStateException("fetch failed") + var calls = 0 + val store = store { + persistence(sourceOfTruth) + fetcher { + when (++calls) { + 1 -> "v1" + 2 -> { + secondStarted.complete(Unit) + releaseSecond.await() + throw boom + } + + else -> error("unexpected fetch call $calls") + } + } + } + + try { + assertEquals("v1", store.get(key)) + store.invalidate(key) + store.stream(key).test { + val stale = assertIs>(awaitItem()) + assertTrue(stale.isStale) + assertTrue(stale.refreshing) + secondStarted.await() + + sourceOfTruth.delete(key) + assertIs(awaitItem()) + releaseSecond.complete(Unit) + + val failure = assertIs(awaitItem()) + assertIs(failure.error) + assertFalse(failure.servedStale) + cancelAndIgnoreRemainingEvents() + } + } finally { + releaseSecond.complete(Unit) + store.close() + } + } + + @Test + fun fetchFailureWhileReactiveStaleValueRemains_reportsServedStaleTrue() = runTest { + val sourceOfTruth = MutableSourceOfTruth() + val secondStarted = CompletableDeferred() + val releaseSecond = CompletableDeferred() + var calls = 0 + val store = store { + persistence(sourceOfTruth) + fetcher { + when (++calls) { + 1 -> "v1" + 2 -> { + secondStarted.complete(Unit) + releaseSecond.await() + throw IllegalStateException("fetch failed") + } + + else -> error("unexpected fetch call $calls") + } + } + } + + try { + assertEquals("v1", store.get(key)) + store.invalidate(key) + store.stream(key).test { + val stale = assertIs>(awaitItem()) + assertTrue(stale.isStale) + secondStarted.await() + releaseSecond.complete(Unit) + + val failure = assertIs(awaitItem()) + assertIs(failure.error) + assertTrue(failure.servedStale) + cancelAndIgnoreRemainingEvents() + } + } finally { + releaseSecond.complete(Unit) + store.close() + } + } + + @Test + fun externalNullMetaRow_underMaxAgeEmitsLoadingUntilFresh() = runTest { + val sourceOfTruth = MutableSourceOfTruth(initial = "durable") + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + val store = store { + persistence(sourceOfTruth) + fetcher { + fetchStarted.complete(Unit) + releaseFetch.await() + "fresh" + } + } + + try { + store.stream(key, Freshness.MaxAge(5.minutes)).test { + assertIs(awaitItem()) + fetchStarted.await() + sourceOfTruth.liveReaderStarted.await() + releaseFetch.complete(Unit) + assertEquals("fresh", assertIs>(awaitItem()).value) + cancelAndIgnoreRemainingEvents() + } + } finally { + releaseFetch.complete(Unit) + store.close() + } + } + + @Test + fun serverDeleteFailure_reportsTimestampedPersistence_withoutDestructiveMutation() = runTest { + val boom = IllegalStateException("delete rejected") + val sourceOfTruth = GatedFailingServerDeleteSourceOfTruth(failure = boom) + val bookkeeper = FailureTrackingBookkeeper() + val clock = FakeWallClock(now = 400L) + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> FetcherResult.Deleted + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = bookkeeper, + validator = DefaultFreshnessValidator, + wallClock = clock, + engineScope = backgroundScope, + ) + + val seed = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.get(Freshness.CachedOrFetch) + } + runCurrent() + assertEquals("v1", seed.await()) + val stateBefore = engine.state.value + val statusBefore = assertNotNull(bookkeeper.status(key)) + assertEquals("v1", sourceOfTruth.current) + + clock.now = 425L + val deleting = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { engine.get(Freshness.MustBeFresh) } + } + val ticket = assertIs(engine.state.value.fetch).ticket + runCurrent() + sourceOfTruth.deleteStarted.await() + assertEquals(1, sourceOfTruth.deleteCalls) + sourceOfTruth.releaseDelete.complete(Unit) + + val failure = assertIs(deleting.await().exceptionOrNull()) + val persistence = assertIs(failure.error) + assertTrue(persistence.cause === boom) + assertTrue(persistence.message.contains("server-side deletion failed")) + val outcome = assertIs(ticket.outcome.await()) + assertEquals(425L, outcome.atEpochMillis) + assertFalse(outcome.bookkeepingRecorded) + assertIs(outcome.exception.error) + assertEquals(FetchDisposition.Failed, ticket.disposition.value) + + val stateAfter = engine.state.value + assertEquals(stateBefore.staleEpoch, stateAfter.staleEpoch) + assertEquals(stateBefore.clearEpoch, stateAfter.clearEpoch) + assertEquals(stateBefore.readerGen, stateAfter.readerGen) + assertTrue(stateAfter.attribution === stateBefore.attribution) + assertEquals(FetchSlot.Idle, stateAfter.fetch) + assertEquals("v1", sourceOfTruth.current) + assertEquals("v1", engine.get(Freshness.LocalOnly)) + assertEquals(listOf(425L), bookkeeper.failureTimes) + assertEquals(0, bookkeeper.forgetCalls) + val statusAfter = assertNotNull(bookkeeper.status(key)) + assertTrue(statusAfter.meta === statusBefore.meta) + assertEquals(statusBefore.lastSuccessSequence, statusAfter.lastSuccessSequence) + assertEquals(425L, statusAfter.lastFailureAtEpochMillis) + assertEquals(1, statusAfter.consecutiveFailures) + } + + @Test + fun clear_waitingForWriteLock_isCancellableBeforeDeleteStarts() = runTest { + val sourceOfTruth = GatedDeleteSourceOfTruth() + val store = store { + persistence(sourceOfTruth) + fetcher { error("fetch must not run") } + } + + try { + assertEquals("seed", store.get(key, Freshness.LocalOnly)) + val first = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.clear(key) + } + sourceOfTruth.deleteStarted.await() + val second = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.clear(key) + } + runCurrent() + + second.cancel(CancellationException("cancel queued clear")) + sourceOfTruth.releaseDelete.complete(Unit) + first.await() + assertIs(runCatching { second.await() }.exceptionOrNull()) + assertEquals(1, sourceOfTruth.deleteCalls) + } finally { + sourceOfTruth.releaseDelete.complete(Unit) + store.close() + } + } + + @Test + fun clear_afterDeleteStarts_finishesIrreversibleTailUnderCancellation() = runTest { + val sourceOfTruth = GatedDeleteSourceOfTruth() + val bookkeeper = ForgetSignallingBookkeeper() + val store = storeWith(bookkeeper = bookkeeper) { + persistence(sourceOfTruth) + fetcher { error("fetch must not run") } + } + + try { + assertEquals("seed", store.get(key, Freshness.LocalOnly)) + val clear = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.clear(key) + } + sourceOfTruth.deleteStarted.await() + clear.cancel(CancellationException("cancel after delete started")) + sourceOfTruth.releaseDelete.complete(Unit) + withTimeout(2_000L) { bookkeeper.forgotten.await() } + + val missing = + runCatching { store.get(key, Freshness.LocalOnly) } + .exceptionOrNull() as? StoreException + assertIs(missing?.error) + assertEquals(1, sourceOfTruth.deleteCalls) + } finally { + sourceOfTruth.releaseDelete.complete(Unit) + store.close() + } + } + + @Test + fun backpressuredReaderDelivery_doesNotBlockClearMutation() = runTest { + val sourceOfTruth = BackpressureSourceOfTruth() + val store = store { + persistence(sourceOfTruth) + fetcher { error("fetch must not run") } + } + val collector = + store.stream(key, Freshness.LocalOnly) + .buffer(capacity = 0) + .produceIn(backgroundScope) + + try { + assertEquals("seed", assertIs>(collector.receive()).value) + sourceOfTruth.liveReaderStarted.await() + sourceOfTruth.publishExternal("external") + awaitLocalValue(store, "external") + sourceOfTruth.externalReaderObserved.await() + + val clear = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.clear(key) + } + withContext(Dispatchers.Default) { + sourceOfTruth.deleteStarted.await() + } + clear.await() + } finally { + collector.cancel() + store.close() + } + } + + @Test + fun synchronousDeleteEcho_doesNotLaunchFetchBeforeClearTail() = runTest { + val sourceOfTruth = GatedDeleteEchoSourceOfTruth() + var fetchCalls = 0 + val store = store { + persistence(sourceOfTruth) + fetcher { "fetched-${++fetchCalls}" } + } + + try { + assertEquals("fetched-1", store.get(key)) + app.cash.turbine.turbineScope { + val collector = store.stream(key).testIn(backgroundScope) + assertEquals( + "fetched-1", + assertIs>(collector.awaitItem()).value, + ) + sourceOfTruth.liveReaderStarted.await() + + val clear = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.clear(key) + } + sourceOfTruth.deleteEchoPublished.await() + assertEquals(1, fetchCalls) + + sourceOfTruth.releaseDelete.complete(Unit) + clear.await() + collector.cancelAndIgnoreRemainingEvents() + } + } finally { + sourceOfTruth.releaseDelete.complete(Unit) + store.close() + } + } + + @Test + fun externalAbsentAfterVisibleLocalData_emitsLoadingThenOneMissingAndResetsDedup() = runTest { + val sourceOfTruth = MutableSourceOfTruth(initial = "seed") + val store = store { + persistence(sourceOfTruth) + fetcher { error("fetch must not run") } + } + + try { + store.stream(key, Freshness.LocalOnly).test { + assertEquals("seed", assertIs>(awaitItem()).value) + + sourceOfTruth.delete(key) + assertIs(awaitItem()) + assertIs(assertIs(awaitItem()).error) + expectNoEvents() + + sourceOfTruth.write(key, "seed") + assertEquals("seed", assertIs>(awaitItem()).value) + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun committedCachedStream_waitsForSourceOfTruthReaderRowBeforeData() = runTest { + val sourceOfTruth = WithheldReaderEchoSourceOfTruth() + val bookkeeper = GateNextSuccessBookkeeper().also { it.gateNextSuccess() } + val store = storeWith(bookkeeper = bookkeeper) { + persistence(sourceOfTruth) + fetcher { + sourceOfTruth.liveReaderStarted.await() + "fetched" + } + } + + try { + store.stream(key).test { + assertIs(awaitItem()) + sourceOfTruth.writeReturned.await() + bookkeeper.successEntered.await() + expectNoEvents() + + bookkeeper.releaseSuccess.complete(Unit) + sourceOfTruth.liveReaderStarted.await() + expectNoEvents() + sourceOfTruth.releaseReaderEcho.complete(Unit) + assertEquals("fetched", assertIs>(awaitItem()).value) + cancelAndIgnoreRemainingEvents() + } + } finally { + bookkeeper.releaseSuccess.complete(Unit) + sourceOfTruth.releaseReaderEcho.complete(Unit) + store.close() + } + } + + @Test + fun committedMustBeFreshStream_waitsForSourceOfTruthReaderRowBeforeData() = runTest { + val sourceOfTruth = WithheldReaderEchoSourceOfTruth() + val bookkeeper = GateNextSuccessBookkeeper().also { it.gateNextSuccess() } + val store = storeWith(bookkeeper = bookkeeper) { + persistence(sourceOfTruth) + fetcher { "fetched" } + } + + try { + store.stream(key, Freshness.MustBeFresh).test { + assertIs(awaitItem()) + sourceOfTruth.writeReturned.await() + bookkeeper.successEntered.await() + expectNoEvents() + + bookkeeper.releaseSuccess.complete(Unit) + sourceOfTruth.liveReaderStarted.await() + expectNoEvents() + sourceOfTruth.releaseReaderEcho.complete(Unit) + assertEquals("fetched", assertIs>(awaitItem()).value) + cancelAndIgnoreRemainingEvents() + } + } finally { + bookkeeper.releaseSuccess.complete(Unit) + sourceOfTruth.releaseReaderEcho.complete(Unit) + store.close() + } + } + + @Test + fun newCollectorDuringSettleToWrite_canLaunchOneRedundantFetch() = runTest { + val sourceOfTruth = GatedWriteTailSourceOfTruth() + val secondFetchStarted = CompletableDeferred() + var fetchCalls = 0 + val store = store { + persistence(sourceOfTruth) + fetcher { + val call = ++fetchCalls + if (call == 2) secondFetchStarted.complete(Unit) + "fetched-$call" + } + } + + try { + assertEquals("seed", store.get(key, Freshness.LocalOnly)) + store.invalidate(key) + + app.cash.turbine.turbineScope { + val first = store.stream(key).testIn(backgroundScope) + assertEquals("seed", assertIs>(first.awaitItem()).value) + sourceOfTruth.writeStarted.await() + + val second = store.stream(key).testIn(backgroundScope) + assertEquals("seed", assertIs>(second.awaitItem()).value) + secondFetchStarted.await() + assertEquals(2, fetchCalls) + + sourceOfTruth.releaseWrite.complete(Unit) + first.cancelAndIgnoreRemainingEvents() + second.cancelAndIgnoreRemainingEvents() + } + } finally { + sourceOfTruth.releaseWrite.complete(Unit) + store.close() + } + } + + @Test + fun preSubscribedCollectors_waitThroughQueuedAbsentThenDeliverWriterCurrentEcho() = runTest { + val sourceOfTruth = QueuedAbsentWriteSourceOfTruth() + val firstFetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + val secondFetchStarted = CompletableDeferred() + var fetchCalls = 0 + val store = store { + persistence(sourceOfTruth) + fetcher { + val call = ++fetchCalls + when (call) { + 1 -> { + firstFetchStarted.complete(Unit) + releaseFetch.await() + "fresh" + } + + 2 -> { + secondFetchStarted.complete(Unit) + "unexpected" + } + + else -> error("unexpected fetch call $call") + } + } + } + + try { + assertEquals("seed", store.get(key, Freshness.LocalOnly)) + store.invalidate(key) + + app.cash.turbine.turbineScope { + val first = store.stream(key).testIn(backgroundScope) + val second = store.stream(key).testIn(backgroundScope) + assertEquals("seed", assertIs>(first.awaitItem()).value) + assertEquals("seed", assertIs>(second.awaitItem()).value) + firstFetchStarted.await() + withContext(Dispatchers.Default) { + sourceOfTruth.awaitLiveReaderSubscription() + } + assertEquals(1, fetchCalls) + + releaseFetch.complete(Unit) + sourceOfTruth.queuedAbsentEmitted.await() + assertIs(first.awaitItem()) + assertIs(second.awaitItem()) + + sourceOfTruth.releaseWriterEcho.complete(Unit) + val firstEcho = assertIs>(first.awaitItem()) + val secondEcho = assertIs>(second.awaitItem()) + assertEquals("fresh", firstEcho.value) + assertEquals("fresh", secondEcho.value) + assertEquals(Origin.FETCHER, firstEcho.origin) + assertEquals(Origin.FETCHER, secondEcho.origin) + assertFalse(firstEcho.refreshing) + assertFalse(secondEcho.refreshing) + testScheduler.runCurrent() + assertFalse(secondFetchStarted.isCompleted) + assertEquals(1, fetchCalls) + first.expectNoEvents() + second.expectNoEvents() + first.cancelAndIgnoreRemainingEvents() + second.cancelAndIgnoreRemainingEvents() + } + } finally { + releaseFetch.complete(Unit) + sourceOfTruth.releaseWriterEcho.complete(Unit) + store.close() + } + } + + @Test + fun externalDeleteAfterWriteReturnsBeforeOutcome_isAuthoritativeAndReplans() = runTest { + val sourceOfTruth = MutableSourceOfTruth(initial = "seed") + val bookkeeper = GateNextSuccessBookkeeper() + val firstFetchStarted = CompletableDeferred() + val releaseFirstFetch = CompletableDeferred() + val secondFetchStarted = CompletableDeferred() + var fetchCalls = 0 + val store = storeWith(bookkeeper = bookkeeper) { + persistence(sourceOfTruth) + fetcher { + when (++fetchCalls) { + 1 -> { + firstFetchStarted.complete(Unit) + releaseFirstFetch.await() + "candidate" + } + 2 -> { + secondFetchStarted.complete(Unit) + "recovered" + } + + else -> error("unexpected fetch call $fetchCalls") + } + } + } + + try { + assertEquals("seed", store.get(key, Freshness.LocalOnly)) + bookkeeper.gateNextSuccess() + + store.stream(key).test { + assertEquals("seed", assertIs>(awaitItem()).value) + firstFetchStarted.await() + releaseFirstFetch.complete(Unit) + withContext(Dispatchers.Default) { + bookkeeper.successEntered.await() + } + assertEquals("candidate", assertIs>(awaitItem()).value) + + sourceOfTruth.delete(key) + assertIs(awaitItem()) + bookkeeper.releaseSuccess.complete(Unit) + + withContext(Dispatchers.Default) { + secondFetchStarted.await() + } + assertEquals("recovered", assertIs>(awaitItem()).value) + assertEquals(2, fetchCalls) + cancelAndIgnoreRemainingEvents() + } + } finally { + releaseFirstFetch.complete(Unit) + bookkeeper.releaseSuccess.complete(Unit) + store.close() + } + } + + @Test + fun conformingIntermediateAbsent_convergesFinalWriterWithoutRevalidation() = runTest { + val sourceOfTruth = GatedAppliedWriteSourceOfTruth() + val firstFetchStarted = CompletableDeferred() + val releaseFirstFetch = CompletableDeferred() + val secondFetchStarted = CompletableDeferred() + var fetchCalls = 0 + val store = store { + persistence(sourceOfTruth) + fetcher { + when (val call = ++fetchCalls) { + 1 -> { + firstFetchStarted.complete(Unit) + releaseFirstFetch.await() + "candidate" + } + + else -> { + secondFetchStarted.complete(Unit) + error("unexpected fetch call $call") + } + } + } + } + + try { + assertEquals("seed", store.get(key, Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + sourceOfTruth.publishExternal("sync") + assertEquals("sync", assertIs>(observer.awaitItem()).value) + sourceOfTruth.publishExternal("seed") + assertEquals("seed", assertIs>(observer.awaitItem()).value) + + val collector = store.stream(key).testIn(backgroundScope) + assertEquals("seed", assertIs>(collector.awaitItem()).value) + firstFetchStarted.await() + observer.cancelAndIgnoreRemainingEvents() + releaseFirstFetch.complete(Unit) + sourceOfTruth.firstWriteApplied.await() + + sourceOfTruth.publishExternalDelete() + sourceOfTruth.releaseFirstWrite.complete(Unit) + + val candidate = assertIs>(collector.awaitItem()) + assertEquals("candidate", candidate.value) + assertEquals(Origin.FETCHER, candidate.origin) + assertFalse(candidate.isStale) + assertFalse(candidate.refreshing) + assertFalse(secondFetchStarted.isCompleted) + assertEquals(1, fetchCalls) + collector.expectNoEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } finally { + releaseFirstFetch.complete(Unit) + sourceOfTruth.releaseFirstWrite.complete(Unit) + store.close() + } + } + + @Test + fun hydratedNullMeta_staleIfErrorWaitsForFailureBeforeFallingBack() = runTest { + val sourceOfTruth = MutableSourceOfTruth(initial = "durable") + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + val store = store { + persistence(sourceOfTruth) + fetcher { + fetchStarted.complete(Unit) + releaseFetch.await() + throw IllegalStateException("offline") + } + } + + try { + val read = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.get(key, Freshness.StaleIfError) + } + fetchStarted.await() + assertFalse(read.isCompleted) + + releaseFetch.complete(Unit) + assertEquals("durable", read.await()) + } finally { + releaseFetch.complete(Unit) + store.close() + } + } + + @Test + fun activeMaxAgeCollector_withholdsWriterRowThatExpiresBeforeReaderDelivery() = runTest { + val clock = FakeWallClock(now = 0L) + val sourceOfTruth = GatedSecondWriteSourceOfTruth() + val thirdFetchStarted = CompletableDeferred() + var calls = 0 + val store = storeWith(clock = clock) { + persistence(sourceOfTruth) + fetcher { + when (++calls) { + 1 -> "v1" + 2 -> "v2" + 3 -> { + thirdFetchStarted.complete(Unit) + "v3" + } + + else -> error("unexpected fetch call $calls") + } + } + } + + try { + app.cash.turbine.turbineScope { + val observer = store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + sourceOfTruth.publishExternal("sync") + assertEquals("sync", assertIs>(observer.awaitItem()).value) + sourceOfTruth.publishExternal("seed") + assertEquals("seed", assertIs>(observer.awaitItem()).value) + + assertEquals("v1", store.get(key, Freshness.MustBeFresh)) + val hydrated = assertIs>(observer.awaitItem()) + assertEquals("v1", hydrated.value) + assertEquals(Origin.FETCHER, hydrated.origin) + + val collector = + store.stream(key, Freshness.MaxAge(5.minutes)).testIn(backgroundScope) + assertEquals("v1", assertIs>(collector.awaitItem()).value) + store.invalidate(key) + assertIs(collector.awaitItem()) + withContext(Dispatchers.Default) { + sourceOfTruth.secondWriteStarted.await() + } + + clock.now = 10.minutes.inWholeMilliseconds + sourceOfTruth.releaseSecondWrite.complete(Unit) + + withContext(Dispatchers.Default) { + thirdFetchStarted.await() + } + assertEquals( + "v3", + assertIs>(collector.awaitItem()).value, + ) + assertEquals(3, calls) + observer.cancelAndIgnoreRemainingEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } finally { + sourceOfTruth.releaseSecondWrite.complete(Unit) + store.close() + } + } + + @Test + fun mustBeFreshCommittedOutcome_doesNotDeliverNewerExternalNullMetaRow() = runTest { + val sourceOfTruth = FirstWriteWithheldSourceOfTruth() + val bookkeeper = GateNextSuccessBookkeeper() + var fetchCalls = 0 + val store = storeWith(bookkeeper = bookkeeper) { + persistence(sourceOfTruth) + fetcher { if (++fetchCalls == 1) "candidate" else "fresh" } + } + + try { + app.cash.turbine.turbineScope { + val observer = store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + bookkeeper.gateNextSuccess() + + val fresh = store.stream(key, Freshness.MustBeFresh).testIn(backgroundScope) + assertIs(fresh.awaitItem()) + sourceOfTruth.firstWriteReturned.await() + bookkeeper.successEntered.await() + + sourceOfTruth.publishExternal("external") + while (true) { + val item = observer.awaitItem() + if (item is StoreResult.Data && item.value == "external") break + } + bookkeeper.releaseSuccess.complete(Unit) + + assertEquals("fresh", assertIs>(fresh.awaitItem()).value) + assertEquals(2, fetchCalls) + observer.cancelAndIgnoreRemainingEvents() + fresh.cancelAndIgnoreRemainingEvents() + } + } finally { + bookkeeper.releaseSuccess.complete(Unit) + store.close() + } + } + + @Test + fun queuedAbsentBeforeWriterEcho_getReturnsCommitAndPipelineKeepsHonestOrigin() = runTest { + val sourceOfTruth = QueuedAbsentAfterSeedSourceOfTruth() + val store = store { + persistence(sourceOfTruth) + fetcher { "candidate" } + } + + try { + assertEquals("seed", store.get(key, Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + sourceOfTruth.publishExternal("sync") + assertEquals("sync", assertIs>(observer.awaitItem()).value) + sourceOfTruth.publishExternal("seed") + assertEquals("seed", assertIs>(observer.awaitItem()).value) + store.invalidate(key) + + val fetched = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.get(key, Freshness.MustBeFresh) + } + sourceOfTruth.queuedAbsentEmitted.await() + assertIs(observer.awaitItem()) + assertIs( + assertIs(observer.awaitItem()).error, + ) + + sourceOfTruth.releaseWriterEcho.complete(Unit) + assertEquals("candidate", fetched.await()) + val echo = assertIs>(observer.awaitItem()) + assertEquals("candidate", echo.value) + assertEquals(Origin.FETCHER, echo.origin) + assertFalse(echo.isStale) + observer.cancelAndIgnoreRemainingEvents() + } + } finally { + sourceOfTruth.releaseWriterEcho.complete(Unit) + store.close() + } + } + + @Test + fun closeDuringSourceOfTruthWrite_cancelsWriteAndDoesNotRecordSuccess() = runTest { + val sourceOfTruth = CancellationWriteSourceOfTruth() + val bookkeeper = InMemoryBookkeeper() + val store = storeWith(bookkeeper = bookkeeper) { + persistence(sourceOfTruth) + fetcher { "value" } + } + + val read = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { store.get(key) } + } + sourceOfTruth.writeStarted.await() + store.close() + + withContext(Dispatchers.Default) { + sourceOfTruth.writeCancelled.await() + } + assertNotNull(read.await().exceptionOrNull()) + assertNull(sourceOfTruth.current) + assertNull(bookkeeper.status(key)) + } + + @Test + fun closeDuringServerDelete_finishesDeleteStateAndBookkeeperTail() = runTest { + val sourceOfTruth = GatedServerDeleteSourceOfTruth() + val bookkeeper = ForgetSignallingBookkeeper() + var calls = 0 + val store = storeWith(bookkeeper = bookkeeper) { + persistence(sourceOfTruth) + fetcherOfResult { + when (++calls) { + 1 -> FetcherResult.Success("v1") + 2 -> FetcherResult.Deleted + else -> error("unexpected fetch call $calls") + } + } + } + + assertEquals("v1", store.get(key)) + val deleting = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { store.get(key, Freshness.MustBeFresh) } + } + sourceOfTruth.deleteStarted.await() + store.close() + sourceOfTruth.releaseDelete.complete(Unit) + + withContext(Dispatchers.Default) { + bookkeeper.forgotten.await() + } + assertNotNull(deleting.await().exceptionOrNull()) + assertNull(sourceOfTruth.current) + assertNull(bookkeeper.status(key)) + } + + private suspend fun awaitLocalValue( + store: Store, + expected: String, + ) { + // Preserve Default-dispatch ordering and let the suite-level runTest bound own cancellation. + withContext(Dispatchers.Default) { + store.stream(key, Freshness.LocalOnly).first { result -> + result is StoreResult.Data && result.value == expected + } + } + } + + /** Waits for the already-active observer to deliver [expected], proving its pipeline caught up. */ + private suspend fun ReceiveTurbine>.awaitDataValue(expected: String) { + while (true) { + val item = awaitItem() + if (item is StoreResult.Data && item.value == expected) return + } + } + + private fun assertExternalSotStale(data: StoreResult.Data) { + assertEquals("external", data.value) + assertEquals(Origin.SOT, data.origin) + assertTrue(data.isStale) + } + + private class GatedFailingServerDeleteSourceOfTruth( + private val failure: Throwable, + ) : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow(null) + val deleteStarted = CompletableDeferred() + val releaseDelete = CompletableDeferred() + var deleteCalls: Int = 0 + private set + val current: String? + get() = rows.value + + override fun reader(key: TestKey): Flow = rows + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.value = value + } + + override suspend fun delete(key: TestKey) { + deleteCalls += 1 + deleteStarted.complete(Unit) + releaseDelete.await() + throw failure + } + } + + private class FailureTrackingBookkeeper : Bookkeeper { + private val delegate = InMemoryBookkeeper() + val failureTimes = mutableListOf() + var forgetCalls: Int = 0 + private set + + override suspend fun recordSuccess( + key: StoreKey, + meta: StoreMeta, + ) { + delegate.recordSuccess(key, meta) + } + + override suspend fun recordFailure( + key: StoreKey, + atEpochMillis: Long, + ) { + failureTimes += atEpochMillis + delegate.recordFailure(key, atEpochMillis) + } + + override suspend fun status(key: StoreKey): KeyStatus? = delegate.status(key) + + override suspend fun forget(key: StoreKey) { + forgetCalls += 1 + delegate.forget(key) + } + + override suspend fun markStale(key: StoreKey) = delegate.markStale(key) + + override suspend fun advanceStaleWatermark(namespace: StoreNamespace) = + delegate.advanceStaleWatermark(namespace) + + override suspend fun advanceGlobalStaleWatermark() = + delegate.advanceGlobalStaleWatermark() + + override suspend fun forgetNamespace(namespace: StoreNamespace) = + delegate.forgetNamespace(namespace) + + override suspend fun forgetAll() = delegate.forgetAll() + } + + private class MutableSourceOfTruth( + initial: String? = null, + ) : SingleRowTestSourceOfTruth { + private data class VersionedRow( + val value: String?, + val version: Long, + ) + + private val rows = MutableStateFlow(VersionedRow(initial, version = 0L)) + private var readerCalls = 0 + val liveReaderStarted = CompletableDeferred() + var deleteFailure: Throwable? = null + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + if (readerCalls >= 2) liveReaderStarted.complete(Unit) + return flow { + rows.collect { row -> emit(row.value) } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + publish(value) + } + + override suspend fun delete(key: TestKey) { + deleteFailure?.let { throw it } + publish(null) + } + + fun publishExternal(value: String) { + publish(value) + } + + private fun publish(value: String?) { + rows.value = + VersionedRow( + value = value, + version = rows.value.version + 1L, + ) + } + } + + private class ReplayableSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = kotlinx.coroutines.flow.MutableSharedFlow(replay = 1) + private var readerCalls = 0 + private var pendingReplayObservation: CompletableDeferred? = null + val liveReaderStarted = CompletableDeferred() + + init { + rows.tryEmit(null) + } + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + if (readerCalls >= 2) liveReaderStarted.complete(Unit) + return flow { + rows.collect { row -> + emit(row) + pendingReplayObservation?.complete(Unit) + } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.emit(value) + } + + override suspend fun delete(key: TestKey) { + rows.emit(null) + } + + suspend fun replay(value: String) { + val observed = CompletableDeferred() + check(pendingReplayObservation == null) { "A replay acknowledgement is already pending." } + pendingReplayObservation = observed + rows.emit(value) + observed.await() + if (pendingReplayObservation === observed) pendingReplayObservation = null + } + + suspend fun publishExternal(value: String) { + rows.emit(value) + } + } + + private class GatedHydrationSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow("stale") + private var gateFirstReader = true + val readerStarted = CompletableDeferred() + val releaseReader = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + if (!gateFirstReader) return rows + gateFirstReader = false + val snapshot = rows.value + return flow { + readerStarted.complete(Unit) + releaseReader.await() + emit(snapshot) + awaitCancellation() + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.value = value + } + + override suspend fun delete(key: TestKey) { + rows.value = null + } + } + + private class GateNextSuccessBookkeeper : Bookkeeper { + private val delegate = InMemoryBookkeeper() + private var gateNext = false + val successEntered = CompletableDeferred() + val releaseSuccess = CompletableDeferred() + + fun gateNextSuccess() { + gateNext = true + } + + override suspend fun recordSuccess( + key: StoreKey, + meta: StoreMeta, + ) { + if (gateNext) { + gateNext = false + successEntered.complete(Unit) + releaseSuccess.await() + } + delegate.recordSuccess(key, meta) + } + + override suspend fun recordFailure( + key: StoreKey, + atEpochMillis: Long, + ) { + delegate.recordFailure(key, atEpochMillis) + } + + override suspend fun status(key: StoreKey): KeyStatus? = delegate.status(key) + + override suspend fun forget(key: StoreKey) { + delegate.forget(key) + } + + override suspend fun markStale(key: StoreKey) = delegate.markStale(key) + + override suspend fun advanceStaleWatermark(namespace: StoreNamespace) = + delegate.advanceStaleWatermark(namespace) + + override suspend fun advanceGlobalStaleWatermark() = + delegate.advanceGlobalStaleWatermark() + + override suspend fun forgetNamespace(namespace: StoreNamespace) = + delegate.forgetNamespace(namespace) + + override suspend fun forgetAll() = delegate.forgetAll() + } + + private class GraceGateSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow("seed") + private var readerCalls = 0 + private var gateExternal = false + val liveReaderStarted = CompletableDeferred() + val externalEmissionBlocked = CompletableDeferred() + val releaseExternalEmission = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + val call = readerCalls + return flow { + if (call >= 2) liveReaderStarted.complete(Unit) + rows.collect { row -> + if (gateExternal && row == "external") { + externalEmissionBlocked.complete(Unit) + releaseExternalEmission.await() + } + emit(row) + } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.value = value + } + + override suspend fun delete(key: TestKey) { + rows.value = null + } + + fun gateExternalEmission() { + gateExternal = true + } + } + + private class GatedDeleteSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow("seed") + val deleteStarted = CompletableDeferred() + val releaseDelete = CompletableDeferred() + var deleteCalls: Int = 0 + + override fun reader(key: TestKey): Flow = rows + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.value = value + } + + override suspend fun delete(key: TestKey) { + deleteCalls += 1 + deleteStarted.complete(Unit) + releaseDelete.await() + rows.value = null + } + } + + private class BackpressureSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow("seed") + private var readerCalls = 0 + val deleteStarted = CompletableDeferred() + val liveReaderStarted = CompletableDeferred() + val externalReaderObserved = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + if (readerCalls >= 2) liveReaderStarted.complete(Unit) + return flow { + rows.collect { row -> + emit(row) + if (row == "external") externalReaderObserved.complete(Unit) + } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.value = value + } + + override suspend fun delete(key: TestKey) { + deleteStarted.complete(Unit) + rows.value = null + } + + fun publishExternal(value: String) { + rows.value = value + } + } + + private class WithheldReaderEchoSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow(null) + val liveReaderStarted = CompletableDeferred() + val writeReturned = CompletableDeferred() + val releaseReaderEcho = CompletableDeferred() + + override fun reader(key: TestKey): Flow = + flow { + val initial = rows.value + if (initial != null) { + // A long-lived reader may start after the write. Mark it live, but withhold its + // immediate current row behind the same delivery gate as an active-reader echo. + liveReaderStarted.complete(Unit) + releaseReaderEcho.await() + } + emit(initial) + + // A one-shot hydration probe is cancelled by `first()` during the emit above and + // never reaches this collection. A long-lived reader either observes the same + // snapshot as its first StateFlow callback or a write that raced the handoff. + var firstStateFlowEmission = true + rows.collect { row -> + if (firstStateFlowEmission) { + firstStateFlowEmission = false + liveReaderStarted.complete(Unit) + if (row == initial) return@collect + } + releaseReaderEcho.await() + emit(row) + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.value = value + writeReturned.complete(Unit) + } + + override suspend fun delete(key: TestKey) { + rows.value = null + } + } + + private class GatedDeleteEchoSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow(null) + private var readerCalls = 0 + private val deleteInitiated = CompletableDeferred() + val liveReaderStarted = CompletableDeferred() + val deleteEchoPublished = CompletableDeferred() + val releaseDelete = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + if (readerCalls >= 2) liveReaderStarted.complete(Unit) + return flow { + rows.collect { row -> + emit(row) + if (row == null && deleteInitiated.isCompleted) { + deleteEchoPublished.complete(Unit) + } + } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.value = value + } + + override suspend fun delete(key: TestKey) { + deleteInitiated.complete(Unit) + rows.value = null + releaseDelete.await() + } + } + + private class GatedWriteTailSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow("seed") + val writeStarted = CompletableDeferred() + val releaseWrite = CompletableDeferred() + + override fun reader(key: TestKey): Flow = rows + + override suspend fun write( + key: TestKey, + value: String, + ) { + writeStarted.complete(Unit) + releaseWrite.await() + rows.value = value + } + + override suspend fun delete(key: TestKey) { + rows.value = null + } + } + + private class QueuedAbsentWriteSourceOfTruth : SingleRowTestSourceOfTruth { + private val liveRows = kotlinx.coroutines.flow.MutableSharedFlow() + private var readerCalls = 0 + val queuedAbsentEmitted = CompletableDeferred() + val releaseWriterEcho = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + if (readerCalls == 1) return flow { emit("seed") } + return liveRows + } + + suspend fun awaitLiveReaderSubscription() { + liveRows.subscriptionCount.first { count -> count > 0 } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + liveRows.emit(null) + queuedAbsentEmitted.complete(Unit) + releaseWriterEcho.await() + liveRows.emit(value) + } + + override suspend fun delete(key: TestKey) { + liveRows.emit(null) + } + + suspend fun publishExternal(value: String) { + liveRows.emit(value) + } + } + + private class GatedAppliedWriteSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = kotlinx.coroutines.flow.MutableSharedFlow(replay = 1) + private val pendingDeleteObservation = MutableStateFlow?>(null) + private var readerCalls = 0 + private var writes = 0 + val liveReaderStarted = CompletableDeferred() + val firstWriteApplied = CompletableDeferred() + val releaseFirstWrite = CompletableDeferred() + + init { + rows.tryEmit("seed") + } + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + if (readerCalls >= 2) liveReaderStarted.complete(Unit) + return flow { + rows.collect { row -> + emit(row) + if (row == null) pendingDeleteObservation.value?.complete(Unit) + } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + writes += 1 + if (writes == 1) { + rows.emit(value) + firstWriteApplied.complete(Unit) + releaseFirstWrite.await() + } + rows.emit(value) + } + + override suspend fun delete(key: TestKey) { + rows.emit(null) + } + + suspend fun publishExternalDelete() { + val observed = CompletableDeferred() + check(pendingDeleteObservation.compareAndSet(null, observed)) + rows.emit(null) + observed.await() + pendingDeleteObservation.compareAndSet(observed, null) + } + + suspend fun publishExternal(value: String) { + rows.emit(value) + } + } + + private class GatedSecondWriteSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow("seed") + private var readerCalls = 0 + private var writes = 0 + val liveReaderStarted = CompletableDeferred() + val secondWriteStarted = CompletableDeferred() + val releaseSecondWrite = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + if (readerCalls >= 2) liveReaderStarted.complete(Unit) + return rows + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + writes += 1 + if (writes == 2) { + secondWriteStarted.complete(Unit) + releaseSecondWrite.await() + } + rows.value = value + } + + override suspend fun delete(key: TestKey) { + rows.value = null + } + + fun publishExternal(value: String) { + rows.value = value + } + } + + private class QueuedAbsentAfterSeedSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = kotlinx.coroutines.flow.MutableSharedFlow(replay = 1) + private var readerCalls = 0 + val liveReaderStarted = CompletableDeferred() + val queuedAbsentEmitted = CompletableDeferred() + val releaseWriterEcho = CompletableDeferred() + + init { + rows.tryEmit("seed") + } + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + if (readerCalls >= 2) liveReaderStarted.complete(Unit) + return rows + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.emit(null) + queuedAbsentEmitted.complete(Unit) + releaseWriterEcho.await() + rows.emit(value) + } + + override suspend fun delete(key: TestKey) { + rows.emit(null) + } + + suspend fun publishExternal(value: String) { + rows.emit(value) + } + } + + private class CancellationWriteSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow(null) + val writeStarted = CompletableDeferred() + val writeCancelled = CompletableDeferred() + val current: String? + get() = rows.value + + override fun reader(key: TestKey): Flow = rows + + override suspend fun write( + key: TestKey, + value: String, + ) { + writeStarted.complete(Unit) + try { + awaitCancellation() + } finally { + writeCancelled.complete(Unit) + } + } + + override suspend fun delete(key: TestKey) { + rows.value = null + } + } + + private class GatedServerDeleteSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow(null) + val deleteStarted = CompletableDeferred() + val releaseDelete = CompletableDeferred() + val current: String? + get() = rows.value + + override fun reader(key: TestKey): Flow = rows + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.value = value + } + + override suspend fun delete(key: TestKey) { + deleteStarted.complete(Unit) + releaseDelete.await() + rows.value = null + } + } + + private class ForgetSignallingBookkeeper : Bookkeeper { + private val delegate = InMemoryBookkeeper() + val forgotten = CompletableDeferred() + + override suspend fun recordSuccess( + key: StoreKey, + meta: StoreMeta, + ) { + delegate.recordSuccess(key, meta) + } + + override suspend fun recordFailure( + key: StoreKey, + atEpochMillis: Long, + ) { + delegate.recordFailure(key, atEpochMillis) + } + + override suspend fun status(key: StoreKey): KeyStatus? = delegate.status(key) + + override suspend fun forget(key: StoreKey) { + delegate.forget(key) + forgotten.complete(Unit) + } + + override suspend fun markStale(key: StoreKey) = delegate.markStale(key) + + override suspend fun advanceStaleWatermark(namespace: StoreNamespace) = + delegate.advanceStaleWatermark(namespace) + + override suspend fun advanceGlobalStaleWatermark() = + delegate.advanceGlobalStaleWatermark() + + override suspend fun forgetNamespace(namespace: StoreNamespace) = + delegate.forgetNamespace(namespace) + + override suspend fun forgetAll() = delegate.forgetAll() + } + + private class FirstWriteWithheldSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow("seed") + private var writes = 0 + val firstWriteReturned = CompletableDeferred() + + override fun reader(key: TestKey): Flow = rows + + override suspend fun write( + key: TestKey, + value: String, + ) { + writes += 1 + if (writes == 1) { + rows.value = value + firstWriteReturned.complete(Unit) + } else { + rows.value = value + } + } + + override suspend fun delete(key: TestKey) { + rows.value = null + } + + fun publishExternal(value: String) { + rows.value = value + } + } +} + +// Turbine's 3s default would nest inside the 25s shadow. Raising the Turbine deadline above +// the shadow makes runTest the only effective timeout. +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds // strictly > TEST_TIMEOUT: the shadow must fire first + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthCancellationConformanceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthCancellationConformanceTest.kt new file mode 100644 index 000000000..2ec0e084f --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthCancellationConformanceTest.kt @@ -0,0 +1,545 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.test +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest as coroutineRunTest +import kotlinx.coroutines.withContext +import org.mobilenativefoundation.store6.core.internal.DefaultFreshnessValidator +import org.mobilenativefoundation.store6.core.internal.EngineStoreMeta +import org.mobilenativefoundation.store6.core.internal.FetchDisposition +import org.mobilenativefoundation.store6.core.internal.FetchSlot +import org.mobilenativefoundation.store6.core.internal.InMemoryBookkeeper +import org.mobilenativefoundation.store6.core.internal.KeyEngine +import org.mobilenativefoundation.store6.core.internal.KeyId +import org.mobilenativefoundation.store6.core.internal.ResultFetcher +import org.mobilenativefoundation.store6.core.internal.KeyState +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import org.mobilenativefoundation.store6.core.seam.KeyStatus +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class, ExperimentalCoroutinesApi::class) +class SourceOfTruthCancellationConformanceTest { + + @Test + fun writeCancellation_revokesTag_cancelsTicket_andMutatesNothing() = runTest { + val key = TestKey("write-cancellation") + val sot = ThrowingWriteSourceOfTruth() + val bookkeeper = InMemoryBookkeeper() + val engine = engine(key, sot, bookkeeper, backgroundScope) { FetcherResult.Success("value") } + + val read = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { engine.get(Freshness.CachedOrFetch) } + } + sot.writeStarted.await() + val tag = assertNotNull(engine.state.value.attribution) + val ticket = tag.owner + assertEquals(FetchDisposition.Committing::class, ticket.disposition.value::class) + + sot.releaseCancellation.complete(Unit) + val failure = read.await().exceptionOrNull() + + assertIs(failure) + assertTrue(ticket.outcome.isCancelled) + assertEquals(FetchDisposition.Cancelled, ticket.disposition.value) + assertEquals(KeyState.Initial, engine.state.value) + assertNull(sot.current) + assertNull(bookkeeper.status(key)) + + sot.publishExternal("value") + engine.stream(Freshness.LocalOnly).test { + val external = assertIs>(awaitItem()) + assertEquals("value", external.value) + assertEquals(Origin.SOT, external.origin) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun writeCancellation_terminatesOwningStream_andEngineRemainsReusable() = runTest { + val key = TestKey("write-cancellation-stream") + val sot = ThrowingWriteSourceOfTruth() + val bookkeeper = InMemoryBookkeeper() + val engine = engine(key, sot, bookkeeper, backgroundScope) { FetcherResult.Success("value") } + val collector = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { engine.stream(Freshness.CachedOrFetch).collect() } + } + + sot.writeStarted.await() + val ticket = assertNotNull(engine.state.value.attribution).owner + sot.releaseCancellation.complete(Unit) + + val failure = + withContext(Dispatchers.Default) { + collector.await() + }.exceptionOrNull() + assertIs(failure) + assertEquals("write cancelled", failure.message) + assertTrue(ticket.outcome.isCancelled) + assertEquals(FetchDisposition.Cancelled, ticket.disposition.value) + assertEquals(KeyState.Initial, engine.state.value) + assertNull(sot.current) + assertNull(bookkeeper.status(key)) + + sot.publishExternal("external") + engine.stream(Freshness.LocalOnly).test { + val external = assertIs>(awaitItem()) + assertEquals("external", external.value) + assertEquals(Origin.SOT, external.origin) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun clearDeleteCancellation_preservesState() = runTest { + val key = TestKey("clear-delete-cancellation") + val sot = ThrowingDeleteSourceOfTruth(initial = "seed") + val bookkeeper = InMemoryBookkeeper() + val meta = EngineStoreMeta(writtenAtEpochMillis = 10L, etag = "seed") + bookkeeper.recordSuccess(key, meta) + val engine = engine(key, sot, bookkeeper, backgroundScope) { error("fetch must not run") } + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + engine.invalidate() + val stateBefore = engine.state.value + val statusBefore = assertNotNull(bookkeeper.status(key)) + val clear = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { engine.clear() } + } + sot.deleteStarted.await() + + sot.releaseCancellation.complete(Unit) + val failure = clear.await().exceptionOrNull() + + assertIs(failure) + assertEquals(stateBefore, engine.state.value) + assertEquals("seed", sot.current) + assertEquals("seed", engine.get(Freshness.LocalOnly)) + assertTrue(bookkeeper.status(key) === statusBefore) + assertEquals(1, sot.deleteCalls) + } + + @Test + fun serverDeleteCancellation_preservesStateAndCancelsTicket() = runTest { + val key = TestKey("server-delete-cancellation") + val sot = ThrowingDeleteSourceOfTruth(initial = "seed") + val bookkeeper = InMemoryBookkeeper() + val meta = EngineStoreMeta(writtenAtEpochMillis = 20L, etag = "seed") + bookkeeper.recordSuccess(key, meta) + val engine = engine(key, sot, bookkeeper, backgroundScope) { FetcherResult.Deleted } + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + engine.invalidate() + val stateBefore = engine.state.value + val statusBefore = assertNotNull(bookkeeper.status(key)) + val read = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { engine.get(Freshness.MustBeFresh) } + } + sot.deleteStarted.await() + val ticket = assertIs(engine.state.value.fetch).ticket + + sot.releaseCancellation.complete(Unit) + val failure = read.await().exceptionOrNull() + + assertIs(failure) + assertTrue(ticket.outcome.isCancelled) + assertEquals(stateBefore, engine.state.value) + assertEquals("seed", sot.current) + assertEquals("seed", engine.get(Freshness.LocalOnly)) + assertTrue(bookkeeper.status(key) === statusBefore) + assertEquals(1, sot.deleteCalls) + } + + @Test + fun serverDeleteCancellation_terminatesDynamicWatcher_andPreservesResident() = runTest { + val key = TestKey("server-delete-cancellation-stream") + val sot = ThrowingDeleteSourceOfTruth(initial = null) + val bookkeeper = InMemoryBookkeeper() + var fetchCalls = 0 + val engine = + engine(key, sot, bookkeeper, backgroundScope) { + when (++fetchCalls) { + 1 -> FetcherResult.Success("seed") + 2 -> FetcherResult.Deleted + else -> error("unexpected fetch call $fetchCalls") + } + } + + assertEquals("seed", engine.get(Freshness.CachedOrFetch)) + val statusBeforeInvalidation = assertNotNull(bookkeeper.status(key)) + val initialData = CompletableDeferred() + val collector = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { + engine.stream(Freshness.CachedOrFetch).collect { result -> + if (result is StoreResult.Data && result.value == "seed") { + initialData.complete(Unit) + } + } + } + } + initialData.await() + + engine.invalidate() + val statusAfterInvalidation = assertNotNull(bookkeeper.status(key)) + assertTrue(statusAfterInvalidation.durablyStale) + assertEquals(statusBeforeInvalidation.meta, statusAfterInvalidation.meta) + sot.deleteStarted.await() + val ticket = assertIs(engine.state.value.fetch).ticket + sot.releaseCancellation.complete(Unit) + + val failure = + withContext(Dispatchers.Default) { + collector.await() + }.exceptionOrNull() + assertIs(failure) + assertEquals("delete cancelled", failure.message) + assertTrue(ticket.outcome.isCancelled) + assertEquals("seed", sot.current) + assertTrue(bookkeeper.status(key) === statusAfterInvalidation) + assertEquals(2, fetchCalls) + + engine.stream(Freshness.LocalOnly).test { + val resident = assertIs>(awaitItem()) + assertEquals("seed", resident.value) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun commitFetch_cancelAfterDurableWrite_completesBookkeepingAndResidenceAtom() = runTest { + val key = TestKey("post-write-cancellation") + val sot = PostWriteReturnSourceOfTruth() + val bookkeeper = SignallingBookkeeper() + val engineJob = SupervisorJob() + val engineScope = CoroutineScope(coroutineContext + engineJob) + val engine = engine(key, sot, bookkeeper, engineScope) { FetcherResult.Success("durable") } + + try { + val read = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { engine.get(Freshness.CachedOrFetch) } + } + sot.writeApplied.await() + val ticket = assertNotNull(engine.state.value.attribution).owner + + engineJob.cancel(CancellationException("cancel after durable write")) + sot.releaseReturn.complete(Unit) + bookkeeper.successRecorded.await() + runCurrent() + + assertIs(read.await().exceptionOrNull()) + assertEquals("durable", sot.current) + assertNotNull(bookkeeper.status(key)?.meta) + assertIs(engine.state.value.fetch) + assertNull(engine.state.value.attribution) + assertIs(ticket.disposition.value) + assertTrue(ticket.outcome.isCancelled) + } finally { + sot.releaseReturn.complete(Unit) + engineJob.cancel() + } + } + + @Test + fun clear_cancelBeforeWriteLock_performsNoDelete() = runTest { + val key = TestKey("clear-before-write-lock") + val sot = GatedSuccessfulDeleteSourceOfTruth(initial = "seed") + val bookkeeper = InMemoryBookkeeper() + val engine = engine(key, sot, bookkeeper, backgroundScope) { error("fetch must not run") } + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + val first = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.clear() + } + sot.deleteStarted.await() + val second = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.clear() + } + runCurrent() + + second.cancel(CancellationException("cancel before writeLock")) + sot.releaseDelete.complete(Unit) + first.await() + + assertIs(runCatching { second.await() }.exceptionOrNull()) + assertEquals(1, sot.deleteCalls) + assertNull(sot.current) + assertEquals(1L, engine.state.value.clearEpoch) + assertEquals(1L, engine.state.value.readerGen) + } + + @Test + fun commitDeleted_cancelAfterDurableDelete_completesStateAndBookkeepingAtomically() = runTest { + val key = TestKey("post-delete-cancellation") + val sot = PostDeleteReturnSourceOfTruth(initial = "seed") + val bookkeeper = SignallingBookkeeper() + bookkeeper.recordSuccess( + key, + EngineStoreMeta(writtenAtEpochMillis = 30L, etag = "seed"), + ) + val engineJob = SupervisorJob() + val engineScope = CoroutineScope(coroutineContext + engineJob) + val engine = engine(key, sot, bookkeeper, engineScope) { FetcherResult.Deleted } + + try { + assertEquals("seed", engine.get(Freshness.LocalOnly)) + engine.invalidate() + val stateBefore = engine.state.value + val read = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { engine.get(Freshness.MustBeFresh) } + } + sot.deleteApplied.await() + val ticket = assertIs(engine.state.value.fetch).ticket + + engineJob.cancel(CancellationException("cancel after durable delete")) + sot.releaseReturn.complete(Unit) + bookkeeper.forgotten.await() + runCurrent() + + assertIs(read.await().exceptionOrNull()) + assertNull(sot.current) + assertNull(bookkeeper.status(key)) + assertIs(engine.state.value.fetch) + assertEquals(stateBefore.clearEpoch + 1L, engine.state.value.clearEpoch) + assertEquals(stateBefore.readerGen + 1L, engine.state.value.readerGen) + assertEquals(stateBefore.staleEpoch, engine.state.value.staleEpoch) + assertNull(engine.state.value.attribution) + assertEquals(FetchDisposition.Deleted, ticket.disposition.value) + assertTrue(ticket.outcome.isCancelled) + } finally { + sot.releaseReturn.complete(Unit) + engineJob.cancel() + } + } + + private fun engine( + key: TestKey, + sot: SourceOfTruth, + bookkeeper: Bookkeeper, + scope: CoroutineScope, + fetcher: suspend (TestKey) -> FetcherResult, + ): KeyEngine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher(fetcher), + sot = sot, + bookkeeper = bookkeeper, + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 100L), + engineScope = scope, + ) + + private class ThrowingWriteSourceOfTruth : SingleRowTestSourceOfTruth { + private val row = MutableStateFlow(null) + val writeStarted = CompletableDeferred() + val releaseCancellation = CompletableDeferred() + val current: String? + get() = row.value + + override fun reader(key: TestKey): Flow = row + + override suspend fun write( + key: TestKey, + value: String, + ) { + writeStarted.complete(Unit) + releaseCancellation.await() + throw CancellationException("write cancelled") + } + + override suspend fun delete(key: TestKey) { + row.value = null + } + + fun publishExternal(value: String) { + row.value = value + } + } + + private class ThrowingDeleteSourceOfTruth( + initial: String?, + ) : SingleRowTestSourceOfTruth { + private val row = MutableStateFlow(initial) + val deleteStarted = CompletableDeferred() + val releaseCancellation = CompletableDeferred() + var deleteCalls: Int = 0 + private set + val current: String? + get() = row.value + + override fun reader(key: TestKey): Flow = row + + override suspend fun write( + key: TestKey, + value: String, + ) { + row.value = value + } + + override suspend fun delete(key: TestKey) { + deleteCalls++ + deleteStarted.complete(Unit) + releaseCancellation.await() + throw CancellationException("delete cancelled") + } + } + + private class PostWriteReturnSourceOfTruth : SingleRowTestSourceOfTruth { + private val row = MutableStateFlow(null) + val writeApplied = CompletableDeferred() + val releaseReturn = CompletableDeferred() + val current: String? + get() = row.value + + override fun reader(key: TestKey): Flow = row + + override suspend fun write( + key: TestKey, + value: String, + ) { + row.value = value + writeApplied.complete(Unit) + withContext(NonCancellable) { releaseReturn.await() } + } + + override suspend fun delete(key: TestKey) { + row.value = null + } + } + + private class GatedSuccessfulDeleteSourceOfTruth( + initial: String?, + ) : SingleRowTestSourceOfTruth { + private val row = MutableStateFlow(initial) + val deleteStarted = CompletableDeferred() + val releaseDelete = CompletableDeferred() + var deleteCalls: Int = 0 + private set + val current: String? + get() = row.value + + override fun reader(key: TestKey): Flow = row + + override suspend fun write( + key: TestKey, + value: String, + ) { + row.value = value + } + + override suspend fun delete(key: TestKey) { + deleteCalls++ + deleteStarted.complete(Unit) + releaseDelete.await() + row.value = null + } + } + + private class PostDeleteReturnSourceOfTruth( + initial: String?, + ) : SingleRowTestSourceOfTruth { + private val row = MutableStateFlow(initial) + val deleteApplied = CompletableDeferred() + val releaseReturn = CompletableDeferred() + val current: String? + get() = row.value + + override fun reader(key: TestKey): Flow = row + + override suspend fun write( + key: TestKey, + value: String, + ) { + row.value = value + } + + override suspend fun delete(key: TestKey) { + row.value = null + deleteApplied.complete(Unit) + releaseReturn.await() + } + } + + private class SignallingBookkeeper : Bookkeeper { + private val delegate = InMemoryBookkeeper() + val successRecorded = CompletableDeferred() + val forgotten = CompletableDeferred() + + override suspend fun recordSuccess( + key: StoreKey, + meta: StoreMeta, + ) { + delegate.recordSuccess(key, meta) + successRecorded.complete(Unit) + } + + override suspend fun recordFailure( + key: StoreKey, + atEpochMillis: Long, + ) { + delegate.recordFailure(key, atEpochMillis) + } + + override suspend fun status(key: StoreKey): KeyStatus? = delegate.status(key) + + override suspend fun forget(key: StoreKey) { + delegate.forget(key) + forgotten.complete(Unit) + } + + override suspend fun markStale(key: StoreKey) = delegate.markStale(key) + + override suspend fun advanceStaleWatermark(namespace: StoreNamespace) = + delegate.advanceStaleWatermark(namespace) + + override suspend fun advanceGlobalStaleWatermark() = + delegate.advanceGlobalStaleWatermark() + + override suspend fun forgetNamespace(namespace: StoreNamespace) = + delegate.forgetNamespace(namespace) + + override suspend fun forgetAll() = delegate.forgetAll() + } +} + +// Turbine's 3s default would nest inside the 25s shadow. Raising the Turbine deadline above +// the shadow makes runTest the only effective timeout. +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds // strictly > TEST_TIMEOUT: the shadow must fire first + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthConformanceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthConformanceTest.kt new file mode 100644 index 000000000..b7a815779 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthConformanceTest.kt @@ -0,0 +1,497 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.test +import app.cash.turbine.testIn +import app.cash.turbine.turbineScope +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest as coroutineRunTest +import kotlinx.coroutines.withContext +import org.mobilenativefoundation.store6.core.internal.InMemorySourceOfTruth +import org.mobilenativefoundation.store6.core.internal.SharedFlowSourceOfTruth +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalStoreApi::class, ExperimentalCoroutinesApi::class) +class SourceOfTruthConformanceTest { + + // Values arriving via fetch commit are attributed FETCHER. + @Test + fun originHonesty_fetchCommit_emitsFetcher() = runTest { + var calls = 0 + val key = TestKey("1") + val readerProbe = + ReaderDeliveryProbeSourceOfTruth(InMemorySourceOfTruth()) + val store = + store { + fetcher { calls += 1; "v" } + persistence(readerProbe) + } + try { + turbineScope { + // Loading precedes shared-reader enrollment. Keep a public LocalOnly observer live, + // then close the engine-facing delivery edge with the test-only probe. + val seedReader = + store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + val missing = assertIs(seedReader.awaitItem()) + assertIs(missing.error) + assertFalse(missing.servedStale) + assertEquals(0, calls) + readerProbe.awaitCurrentReaderFirstDelivery(key) + + val collector = store.stream(key).testIn(backgroundScope) + assertIs(collector.awaitItem()) + val data = assertIs>(collector.awaitItem()) + assertEquals(Origin.FETCHER, data.origin) + assertFalse(data.isStale) + assertFalse(data.refreshing) + assertEquals(1, calls) + seedReader.cancelAndIgnoreRemainingEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } finally { + store.closeAndSettleForTest() + } + } + + // External data is delivered as SOT/stale before its one active-demand revalidation. + @Test + fun originHonesty_externalSotWrite_emitsSotToActiveStream() = runTest { + var calls = 0 + val revalidationStarted = CompletableDeferred() + val revalidationGate = CompletableDeferred() + val sot = SharedFlowSourceOfTruth() + val store = store { + fetcher { + calls++ + if (calls == 1) { + "fetched" + } else { + revalidationStarted.complete(Unit) + revalidationGate.await() + "revalidated" + } + } + persistence(sot) + } + store.stream(TestKey("1")).test { + assertIs(awaitItem()) + assertEquals("fetched", assertIs>(awaitItem()).value) + + sot.write(TestKey("1"), "external") + + val external = assertIs>(awaitItem()) + assertEquals("external", external.value) + assertEquals(Origin.SOT, external.origin) + assertTrue(external.isStale) + revalidationStarted.await() + assertEquals(2, calls) + revalidationGate.complete(Unit) + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + // The memory fast path serves without waiting for the pipeline, stamped MEMORY. + @Test + fun originHonesty_memoryFastPath_reStampsMemory() = runTest { + val store = store { fetcher { "v" } } + assertEquals("v", store.get(TestKey("1"))) + store.stream(TestKey("1")).test { + val first = assertIs>(awaitItem()) + assertEquals(Origin.MEMORY, first.origin) + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + // The stale-tag interleave: a collector-less commit parks a tag; a later external write must + // not inherit it (value binding). Guards the SoT-read values-never-labeled-FETCHER criterion. + @Test + fun dormantCommit_externalWriteBeforeFirstCollector_attributesSot() = runTest { + val key = TestKey("1") + val sot = SharedFlowSourceOfTruth() + val revalidationGate = CompletableDeferred() + var fetchCalls = 0 + val store = store { + fetcher { + fetchCalls++ + if (fetchCalls == 1) { + "fetched" + } else { + revalidationGate.await() + "revalidated" + } + } + persistence(sot) + } + try { + assertEquals("fetched", store.get(key)) + sot.write(key, "external") + val external = + withContext(Dispatchers.Default) { + store.stream(key) + .filterIsInstance>() + .first { it.value == "external" } + } + assertEquals(Origin.SOT, external.origin) + } finally { + revalidationGate.complete(Unit) + store.close() + } + } + + // The F-1 killer: invalidate with multiple active collectors on the DSL default SoT. + @Test + fun orphanRegression_invalidateWithActiveCollectors_allObserveRefetchedData() = runTest { + var calls = 0 + val firstFetchGate = CompletableDeferred() + val secondFetchStarted = CompletableDeferred() + val secondFetchGate = CompletableDeferred() + val store = store { + fetcher { + val call = ++calls + when (call) { + 1 -> firstFetchGate.await() + 2 -> { + secondFetchStarted.complete(Unit) + secondFetchGate.await() + } + else -> error("unexpected fetch call $call") + } + "v$call" + } + } + try { + turbineScope { + val key = TestKey("1") + val a = store.stream(key).testIn(backgroundScope) + val b = store.stream(key).testIn(backgroundScope) + assertIs(a.awaitItem()) + assertIs(b.awaitItem()) + // Loading is sent before StreamDelivery.start; drain both collectors while fetch 1 + // is blocked so their initial-ticket watchers are parked before the first commit. + runCurrent() + firstFetchGate.complete(Unit) + assertEquals("v1", assertIs>(a.awaitItem()).value) + assertEquals("v1", assertIs>(b.awaitItem()).value) + + store.invalidate(key) + secondFetchStarted.await() + // Fetch 2 is blocked, so draining the test scheduler causally enrolls both active + // collectors on that ticket before it can settle and a late watcher can launch I3. + runCurrent() + assertEquals(2, calls) + secondFetchGate.complete(Unit) + + var aSeen = false + while (!aSeen) { + val item = a.awaitItem() + aSeen = item is StoreResult.Data && item.value == "v2" + } + var bSeen = false + while (!bSeen) { + val item = b.awaitItem() + bSeen = item is StoreResult.Data && item.value == "v2" + } + assertEquals(2, calls) + a.cancelAndIgnoreRemainingEvents() + b.cancelAndIgnoreRemainingEvents() + } + } finally { + firstFetchGate.complete(Unit) + secondFetchGate.complete(Unit) + store.close() + } + } + + // Resubscribe after clear may duplicate, never lose; cleared value never replays. + @Test + fun resubscribeAfterClear_duplicatesNotLosses() = runTest { + var calls = 0 + val store = store { fetcher { calls++; "v$calls" } } + store.stream(TestKey("1")).test { + assertIs(awaitItem()) + assertEquals("v1", assertIs>(awaitItem()).value) + cancelAndIgnoreRemainingEvents() + } + + store.clear(TestKey("1")) + + store.stream(TestKey("1")).test { + var sawLoading = false + var sawFresh = false + while (!sawFresh) { + when (val item = awaitItem()) { + is StoreResult.Loading -> sawLoading = true + is StoreResult.Data -> { + assertTrue(item.value != "v1") + if (item.value == "v2") sawFresh = true + } + else -> Unit + } + } + assertTrue(sawLoading) + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + // Populate, unsubscribe, outlast the grace, clear, then prove stale replay cannot resurrect. + @Test + fun clearWhilePipelineDormant_neverResurrectsClearedValue() = runTest { + var calls = 0 + val store = store { fetcher { calls++; "v$calls" } } + store.stream(TestKey("1")).test { + assertIs(awaitItem()) + assertEquals("v1", assertIs>(awaitItem()).value) + cancelAndIgnoreRemainingEvents() + } + withContext(Dispatchers.Default) { delay(400) } + + store.clear(TestKey("1")) + + store.stream(TestKey("1")).test { + while (true) { + val item = awaitItem() + if (item is StoreResult.Data) { + assertEquals("v2", item.value) + break + } + } + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + // Null-on-delete liveness through Store: external delete -> absent transition, stream live. + @Test + fun externalSotDelete_activeStreamSeesAbsentTransitionAndStaysLive() = runTest { + var calls = 0 + val thirdFetchStarted = CompletableDeferred() + val thirdFetchGate = CompletableDeferred() + val sot = SharedFlowSourceOfTruth() + val store = store { + fetcher { + val call = ++calls + if (call == 3) { + thirdFetchStarted.complete(Unit) + thirdFetchGate.await() + } + "fetched-$call" + } + persistence(sot) + } + try { + store.stream(TestKey("1")).test { + assertIs(awaitItem()) + assertEquals("fetched-1", assertIs>(awaitItem()).value) + + sot.delete(TestKey("1")) + + assertIs(awaitItem()) + while (true) { + val item = awaitItem() + if (item is StoreResult.Data) { + assertEquals("fetched-2", item.value) + assertEquals(Origin.FETCHER, item.origin) + assertFalse(item.isStale) + break + } + } + assertEquals(2, calls) + sot.write(TestKey("1"), "rewritten") + // Keep the valid null-meta revalidation from overtaking the liveness probe. + thirdFetchStarted.await() + while (true) { + val item = awaitItem() + if (item is StoreResult.Data && item.value == "rewritten") { + assertEquals(Origin.SOT, item.origin) + assertTrue(item.isStale) + break + } + } + assertEquals(3, calls) + cancelAndIgnoreRemainingEvents() + } + } finally { + thirdFetchGate.complete(Unit) + store.close() + } + } + + // A pre-populated SoT serves without a fetch under LocalOnly. + @Test + fun localOnly_prePopulatedSot_getServesWithoutFetcher() = runTest { + var calls = 0 + val sot = SharedFlowSourceOfTruth() + sot.write(TestKey("1"), "durable") + val store = store { + fetcher { calls++; "fetched" } + persistence(sot) + } + assertEquals("durable", store.get(TestKey("1"), Freshness.LocalOnly)) + assertEquals(0, calls) + store.close() + } + + // Hydration: unknown provenance serves and triggers exactly one revalidation. + @Test + fun cachedOrFetch_hydratedRow_servesThenRevalidatesExactlyOnce() = runTest { + var calls = 0 + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + val sot = SharedFlowSourceOfTruth() + sot.write(TestKey("1"), "durable") + val store = store { + fetcher { + calls++ + fetchStarted.complete(Unit) + releaseFetch.await() + "fetched" + } + persistence(sot) + } + try { + assertEquals("durable", store.get(TestKey("1"))) + fetchStarted.await() + assertEquals(1, calls) + store.stream(TestKey("1")).test { + val hydrated = assertIs>(awaitItem()) + assertEquals("durable", hydrated.value) + assertTrue(hydrated.refreshing) + + releaseFetch.complete(Unit) + while (true) { + val item = awaitItem() + if (item is StoreResult.Data && item.value == "fetched") break + } + cancelAndIgnoreRemainingEvents() + } + assertEquals("fetched", store.get(TestKey("1"))) + assertEquals(1, calls) + } finally { + releaseFetch.complete(Unit) + store.close() + } + } + + // Persisted truth participates in startup before its revalidation can overwrite it. + @Test + fun cachedOrFetch_prePopulatedSot_streamServesSotBeforeRevalidation() = runTest { + var calls = 0 + val fetchStarted = CompletableDeferred() + val fetchGate = CompletableDeferred() + val sot = SharedFlowSourceOfTruth() + sot.write(TestKey("1"), "durable") + val store = store { + fetcher { + calls++ + fetchStarted.complete(Unit) + fetchGate.await() + "fetched" + } + persistence(sot) + } + store.stream(TestKey("1")).test { + val durable = assertIs>(awaitItem()) + assertEquals("durable", durable.value) + assertEquals(Origin.SOT, durable.origin) + assertTrue(durable.isStale) + assertTrue(durable.refreshing) + + fetchStarted.await() + fetchGate.complete(Unit) + val fetched = assertIs>(awaitItem()) + assertEquals("fetched", fetched.value) + assertEquals(Origin.FETCHER, fetched.origin) + assertEquals(1, calls) + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + // I7: a 304 refreshes metadata and emits Revalidated to the revalidating collector. + @Test + fun notModified_refreshesMetaAndEmitsRevalidated() = runTest { + var calls = 0 + val store = store { + fetcherOfResult { + calls++ + if (calls == 1) { + FetcherResult.Success("v1", etag = "e1") + } else { + FetcherResult.NotModified(etag = "e1") + } + } + } + store.stream(TestKey("1")).test { + assertIs(awaitItem()) + assertEquals("v1", assertIs>(awaitItem()).value) + + store.invalidate(TestKey("1")) + + var item = awaitItem() + while (item is StoreResult.Data && item.isStale) { + item = awaitItem() + } + assertIs(item) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + // I5 enforcement: exercise every writeLock-holding path under concurrency. + @Test + fun lockOrderCanary_concurrentCommitClearStreamAndGet_terminates() = runTest { + var calls = 0 + val store = store { fetcher { calls++; "v$calls" } } + suspend fun getAllowingConcurrentClear() { + try { + store.get(TestKey("1")) + } catch (failure: StoreException) { + assertIs(failure.error) + } + } + withContext(Dispatchers.Default) { + val collector = launch { + store.stream(TestKey("1")).collect { } + } + repeat(10) { + getAllowingConcurrentClear() + store.invalidate(TestKey("1")) + getAllowingConcurrentClear() + store.clear(TestKey("1")) + } + collector.cancel() + } + store.close() + } +} + +// Turbine's 3s default would nest inside the 25s shadow. Raising the Turbine deadline above +// the shadow makes runTest the only effective timeout. +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds // strictly > TEST_TIMEOUT: the shadow must fire first + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthFailureConformanceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthFailureConformanceTest.kt new file mode 100644 index 000000000..362727a98 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthFailureConformanceTest.kt @@ -0,0 +1,569 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.test +import app.cash.turbine.testIn +import app.cash.turbine.turbineScope +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.internal.DefaultFreshnessValidator +import org.mobilenativefoundation.store6.core.internal.InMemoryBookkeeper +import org.mobilenativefoundation.store6.core.internal.KeyEngine +import org.mobilenativefoundation.store6.core.internal.KeyId +import org.mobilenativefoundation.store6.core.internal.ResultFetcher +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +@OptIn(ExperimentalStoreApi::class, ExperimentalCoroutinesApi::class) +class SourceOfTruthFailureConformanceTest { + + @Test + fun readerFailure_activeStreamEmitsTypedPersistenceAndRecovers() = runTest { + val boom = IllegalStateException("reader outage") + val sot = EpisodeSourceOfTruth(initial = "seed", failure = boom) + val engine = localOnlyEngine(sot, backgroundScope) + + engine.stream(Freshness.LocalOnly).test { + assertEquals("seed", assertIs>(awaitItem()).value) + sot.awaitLiveReader() + + sot.failActiveReader(retryFailures = 0) + + val error = assertIs(awaitItem()) + val persistence = assertIs(error.error) + assertMatchingFailure(persistence.cause, boom) + + sot.recoverWith("recovered") + advanceTimeBy(READER_RETRY_MILLIS) + runCurrent() + + assertEquals("recovered", assertIs>(awaitItem()).value) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun readerFailure_initialLocalOnlyDoesNotReportMissing() = runTest { + val boom = IllegalStateException("initial reader outage") + val sot = InitialFailureThenRecoverySourceOfTruth(boom) + val engine = localOnlyEngine(sot, backgroundScope) + + engine.stream(Freshness.LocalOnly).test { + val error = assertIs(awaitItem()) + val persistence = assertIs(error.error) + assertMatchingFailure(persistence.cause, boom) + + advanceTimeBy(READER_RETRY_MILLIS) + runCurrent() + sot.pipelineStarted.await() + expectNoEvents() + + sot.recoverWith("durable") + runCurrent() + + assertEquals("durable", assertIs>(awaitItem()).value) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun readerCompletion_isTypedAndRetriedDefensively() = runTest { + val sot = CompletingSourceOfTruth(initial = "seed") + val engine = localOnlyEngine(sot, backgroundScope) + + engine.stream(Freshness.LocalOnly).test { + assertEquals("seed", assertIs>(awaitItem()).value) + sot.awaitLiveReader() + + sot.completeNormallyWith("recovered") + + val error = assertIs(awaitItem()) + val persistence = assertIs(error.error) + val cause = assertIs(persistence.cause) + assertTrue(cause.message.orEmpty().contains("completed normally")) + + advanceTimeBy(READER_RETRY_MILLIS) + runCurrent() + + assertEquals("recovered", assertIs>(awaitItem()).value) + assertTrue(sot.readerCalls >= 3) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun readerCancellation_cancelsWithoutPersistenceEmission() = runTest { + val sot = CancellingSourceOfTruth(initial = "seed") + val engine = localOnlyEngine(sot, backgroundScope) + + engine.stream(Freshness.LocalOnly).test { + assertEquals("seed", assertIs>(awaitItem()).value) + sot.awaitLiveReader() + val callsBeforeCancellation = sot.readerCalls + + sot.cancelActiveReader() + sot.cancellationThrown.await() + runCurrent() + + expectNoEvents() + assertEquals(callsBeforeCancellation, sot.readerCalls) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun persistentReaderFailure_coalescesEpisode_andStalledCollectorDoesNotBlockRecovery() = + runTest { + val boom = IllegalStateException("persistent reader outage") + val sot = EpisodeSourceOfTruth(initial = "seed", failure = boom) + val key = TestKey("key") + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { FetcherResult.Success("unused") }, + sot = sot, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + ) + + turbineScope { + val stalledSawSeed = CompletableDeferred() + val stalledOnFirstError = CompletableDeferred() + val releaseStalledCollector = CompletableDeferred() + val stalled = + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + engine.stream(Freshness.LocalOnly).buffer(0).collect { result -> + when (result) { + is StoreResult.Data -> { + if (result.value == "seed") stalledSawSeed.complete(Unit) + } + is StoreResult.Error -> { + assertPersistenceFailure(result, boom) + if (stalledOnFirstError.complete(Unit)) { + releaseStalledCollector.await() + } + } + is StoreResult.Loading, + is StoreResult.Revalidated, + -> Unit + } + } + } + val fast = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + stalledSawSeed.await() + assertEquals("seed", assertIs>(fast.awaitItem()).value) + sot.awaitLiveReader() + + val callsAtEpisodeStart = sot.readerCalls + sot.failActiveReader(retryFailures = 3) + + stalledOnFirstError.await() + assertPersistenceFailure(fast.awaitItem(), boom) + + repeat(3) { + advanceTimeBy(READER_RETRY_MILLIS) + runCurrent() + fast.expectNoEvents() + } + assertEquals(callsAtEpisodeStart + 3, sot.readerCalls) + + // The first Error is still blocking the direct zero-buffer collector. Its queued + // recovery must not backpressure the shared retrying reader or the fast peer. + sot.recoverWith("recovered") + advanceTimeBy(READER_RETRY_MILLIS) + runCurrent() + + assertEquals("recovered", assertIs>(fast.awaitItem()).value) + sot.awaitLiveReader() + + sot.failActiveReader(retryFailures = 0) + assertPersistenceFailure(fast.awaitItem(), boom) + + releaseStalledCollector.complete(Unit) + stalled.cancel() + fast.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun externalNullMetaRowServedBeforeFailure_marksErrorServedStale() = runTest { + val boom = IllegalStateException("revalidation failed") + val revalidationStarted = CompletableDeferred() + val releaseRevalidation = CompletableDeferred() + var calls = 0 + val sot = MutableSourceOfTruth() + val store = + store { + persistence(sot) + fetcher { + calls++ + if (calls == 1) { + "seed" + } else { + revalidationStarted.complete(Unit) + releaseRevalidation.await() + throw boom + } + } + } + + try { + store.stream(TestKey("key")).test { + assertIs(awaitItem()) + assertEquals("seed", assertIs>(awaitItem()).value) + runCurrent() + + sot.publishExternal("external") + val external = assertIs>(awaitItem()) + assertEquals("external", external.value) + assertEquals(Origin.SOT, external.origin) + assertTrue(external.isStale) + assertTrue(external.refreshing) + + revalidationStarted.await() + releaseRevalidation.complete(Unit) + + val error = assertIs(awaitItem()) + assertTrue(error.servedStale) + val fetch = assertIs(error.error) + assertMatchingFailure(fetch.cause, boom) + assertEquals(2, calls) + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun activeNullMetaRow_triggersOneRevalidationPerResidenceRevision() = runTest { + val firstFailure = IllegalStateException("first revalidation failed") + val secondStarted = CompletableDeferred() + val releaseSecond = CompletableDeferred() + val thirdStarted = CompletableDeferred() + val releaseThird = CompletableDeferred() + var calls = 0 + val sot = MutableSourceOfTruth() + val store = + store { + persistence(sot) + fetcher { + calls++ + when (calls) { + 1 -> "seed" + 2 -> { + secondStarted.complete(Unit) + releaseSecond.await() + throw firstFailure + } + 3 -> { + thirdStarted.complete(Unit) + releaseThird.await() + "refreshed" + } + else -> error("unexpected revalidation $calls") + } + } + } + + try { + store.stream(TestKey("key")).test { + assertIs(awaitItem()) + assertEquals("seed", assertIs>(awaitItem()).value) + + sot.publishExternal("external-1") + val firstExternal = assertIs>(awaitItem()) + assertEquals("external-1", firstExternal.value) + assertTrue(firstExternal.isStale) + secondStarted.await() + assertEquals(2, calls) + + releaseSecond.complete(Unit) + val firstError = assertIs(awaitItem()) + assertTrue(firstError.servedStale) + assertMatchingFailure( + assertIs(firstError.error).cause, + firstFailure, + ) + runCurrent() + expectNoEvents() + assertEquals(2, calls) + + sot.publishExternal("external-2") + val secondExternal = assertIs>(awaitItem()) + assertEquals("external-2", secondExternal.value) + assertTrue(secondExternal.isStale) + thirdStarted.await() + assertEquals(3, calls) + + releaseThird.complete(Unit) + assertEquals("refreshed", assertIs>(awaitItem()).value) + runCurrent() + assertEquals(3, calls) + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + private fun localOnlyEngine( + sot: SourceOfTruth, + scope: CoroutineScope, + ): KeyEngine { + val key = TestKey("key") + return KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { FetcherResult.Success("unused") }, + sot = sot, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = scope, + ) + } + + private fun assertPersistenceFailure( + result: StoreResult, + cause: Throwable, + ) { + val error = assertIs(result) + val persistence = assertIs(error.error) + assertMatchingFailure(persistence.cause, cause) + } + + private fun assertMatchingFailure( + actual: Throwable?, + expected: Throwable, + ) { + val failure = assertIs(actual) + assertEquals(expected.message, failure.message) + } + + private class EpisodeSourceOfTruth( + initial: String?, + private val failure: Throwable, + ) : SingleRowTestSourceOfTruth { + private var row: String? = initial + private var retryFailuresRemaining = 0 + private val activeFailures = Channel(Channel.UNLIMITED) + private val liveReaderStarts = Channel(Channel.UNLIMITED) + + var readerCalls: Int = 0 + private set + + override fun reader(key: TestKey): Flow { + readerCalls++ + val failBeforeRow = retryFailuresRemaining > 0 + if (failBeforeRow) retryFailuresRemaining-- + return flow { + if (failBeforeRow) throw failure + emit(row) + liveReaderStarts.send(Unit) + throw activeFailures.receive() + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + row = value + } + + override suspend fun delete(key: TestKey) { + row = null + } + + suspend fun awaitLiveReader() { + liveReaderStarts.receive() + } + + fun failActiveReader(retryFailures: Int) { + retryFailuresRemaining = retryFailures + check(activeFailures.trySend(failure).isSuccess) + } + + fun recoverWith(value: String) { + row = value + retryFailuresRemaining = 0 + } + } + + private class InitialFailureThenRecoverySourceOfTruth( + private val failure: Throwable, + ) : SingleRowTestSourceOfTruth { + private var readerCalls = 0 + private var recoveredRow: String? = null + private val releaseRecovery = CompletableDeferred() + val pipelineStarted = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + readerCalls++ + return if (readerCalls == 1) { + flow { throw failure } + } else { + flow { + pipelineStarted.complete(Unit) + releaseRecovery.await() + emit(recoveredRow) + awaitCancellation() + } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + recoveredRow = value + } + + override suspend fun delete(key: TestKey) { + recoveredRow = null + } + + fun recoverWith(value: String) { + recoveredRow = value + releaseRecovery.complete(Unit) + } + } + + private class CompletingSourceOfTruth( + initial: String?, + ) : SingleRowTestSourceOfTruth { + private var row: String? = initial + private var completionConsumed = false + private val releaseCompletion = CompletableDeferred() + private val liveReaderStarts = Channel(Channel.UNLIMITED) + + var readerCalls: Int = 0 + private set + + override fun reader(key: TestKey): Flow { + readerCalls++ + return flow { + emit(row) + liveReaderStarts.send(Unit) + if (!completionConsumed) { + releaseCompletion.await() + completionConsumed = true + return@flow + } + awaitCancellation() + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + row = value + } + + override suspend fun delete(key: TestKey) { + row = null + } + + suspend fun awaitLiveReader() { + liveReaderStarts.receive() + } + + fun completeNormallyWith(value: String) { + row = value + releaseCompletion.complete(Unit) + } + } + + private class CancellingSourceOfTruth( + initial: String?, + ) : SingleRowTestSourceOfTruth { + private var row: String? = initial + private val releaseCancellation = CompletableDeferred() + private val liveReaderStarts = Channel(Channel.UNLIMITED) + val cancellationThrown = CompletableDeferred() + + var readerCalls: Int = 0 + private set + + override fun reader(key: TestKey): Flow { + readerCalls++ + return flow { + emit(row) + liveReaderStarts.send(Unit) + releaseCancellation.await() + cancellationThrown.complete(Unit) + throw CancellationException("reader cancelled") + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + row = value + } + + override suspend fun delete(key: TestKey) { + row = null + } + + suspend fun awaitLiveReader() { + liveReaderStarts.receive() + } + + fun cancelActiveReader() { + releaseCancellation.complete(Unit) + } + } + + private class MutableSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = + MutableSharedFlow( + replay = 1, + extraBufferCapacity = 16, + ).also { flow -> check(flow.tryEmit(null)) } + + override fun reader(key: TestKey): Flow = rows + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.emit(value) + } + + override suspend fun delete(key: TestKey) { + rows.emit(null) + } + + suspend fun publishExternal(value: String) { + rows.emit(value) + } + } + + private companion object { + const val READER_RETRY_MILLIS = 100L + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthHydrationRaceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthHydrationRaceTest.kt new file mode 100644 index 000000000..9814883d7 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthHydrationRaceTest.kt @@ -0,0 +1,245 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.testIn +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs + +@OptIn(ExperimentalStoreApi::class, ExperimentalCoroutinesApi::class) +class SourceOfTruthHydrationRaceTest { + private val key = TestKey("hydration-race") + + @Test + fun getHydrationRacingClear_neverResurrectsAfterClearReturns() = runTest { + val sourceOfTruth = ClearRacingHydrationSourceOfTruth() + val store = store { + persistence(sourceOfTruth) + fetcher { error("fetch must not run") } + } + + try { + val hydration = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.get(key, Freshness.LocalOnly) + } + sourceOfTruth.readerStarted.await() + val clear = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.clear(key) + } + runCurrent() + + sourceOfTruth.releaseReader.complete(Unit) + assertEquals("snapshot", hydration.await()) + clear.await() + + val missing = + assertFailsWith { + store.get(key, Freshness.LocalOnly) + } + assertIs(missing.error) + } finally { + sourceOfTruth.releaseReader.complete(Unit) + store.close() + } + } + + @Test + fun externalAbsentObservedDuringHydration_neverResurrectsSnapshot() = runTest { + val sourceOfTruth = ReactiveHydrationSourceOfTruth() + val store = localOnlyStore(sourceOfTruth) + + try { + app.cash.turbine.turbineScope { + val observer = store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + assertIs( + assertIs(observer.awaitItem()).error, + ) + sourceOfTruth.sharedReaderStarted.await() + runCurrent() + sourceOfTruth.prepareGatedDirectSnapshot("snapshot") + + val hydration = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { store.get(key, Freshness.LocalOnly) } + } + sourceOfTruth.directHydrationStarted.await() + + sourceOfTruth.publish("intermediate") + assertEquals( + "intermediate", + assertIs>(observer.awaitItem()).value, + ) + sourceOfTruth.publish(null) + var sawAbsent = false + while (!sawAbsent) { + when (val item = observer.awaitItem()) { + is StoreResult.Loading -> Unit + is StoreResult.Error -> { + assertIs(item.error) + sawAbsent = true + } + is StoreResult.Data -> error("Absent transition emitted ${item.value}.") + is StoreResult.Revalidated -> error("005 must not emit Revalidated.") + } + } + runCurrent() + + sourceOfTruth.releaseDirectHydration.complete(Unit) + val failure = assertIs(hydration.await().exceptionOrNull()) + assertIs(failure.error) + observer.cancelAndIgnoreRemainingEvents() + } + } finally { + sourceOfTruth.releaseDirectHydration.complete(Unit) + store.close() + } + } + + @Test + fun get_localOnly_directAbsentButReactiveRowWins_usesLiveResidence() = runTest { + val sourceOfTruth = ReactiveHydrationSourceOfTruth() + val store = localOnlyStore(sourceOfTruth) + + try { + app.cash.turbine.turbineScope { + val observer = store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + assertIs( + assertIs(observer.awaitItem()).error, + ) + sourceOfTruth.sharedReaderStarted.await() + runCurrent() + sourceOfTruth.prepareGatedDirectSnapshot(null) + + val hydration = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.get(key, Freshness.LocalOnly) + } + sourceOfTruth.directHydrationStarted.await() + + sourceOfTruth.publish("live") + assertEquals("live", assertIs>(observer.awaitItem()).value) + runCurrent() + + sourceOfTruth.releaseDirectHydration.complete(Unit) + assertEquals("live", hydration.await()) + observer.cancelAndIgnoreRemainingEvents() + } + } finally { + sourceOfTruth.releaseDirectHydration.complete(Unit) + store.close() + } + } + + private fun localOnlyStore(sourceOfTruth: SourceOfTruth): Store = + store { + persistence(sourceOfTruth) + fetcher { error("fetch must not run") } + } + + private class ClearRacingHydrationSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow("snapshot") + private var gateFirstReader = true + val readerStarted = CompletableDeferred() + val releaseReader = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + if (!gateFirstReader) return rows + gateFirstReader = false + val snapshot = rows.value + return flow { + readerStarted.complete(Unit) + releaseReader.await() + emit(snapshot) + awaitCancellation() + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.value = value + } + + override suspend fun delete(key: TestKey) { + rows.value = null + } + } + + private class ReactiveHydrationSourceOfTruth : SingleRowTestSourceOfTruth { + private val liveRows = + MutableSharedFlow( + replay = 1, + extraBufferCapacity = 8, + ).also { rows -> check(rows.tryEmit(null)) } + private var readerCalls = 0 + private var nextDirectSnapshot: String? = null + private var directSnapshotPrepared = false + + val sharedReaderStarted = CompletableDeferred() + var directHydrationStarted = CompletableDeferred() + private set + var releaseDirectHydration = CompletableDeferred() + private set + + fun prepareGatedDirectSnapshot(snapshot: String?) { + check(readerCalls == 2) { "shared reader must be active before direct hydration" } + check(!directSnapshotPrepared) { "a direct hydration is already prepared" } + nextDirectSnapshot = snapshot + directSnapshotPrepared = true + directHydrationStarted = CompletableDeferred() + releaseDirectHydration = CompletableDeferred() + } + + suspend fun publish(value: String?) { + liveRows.emit(value) + } + + override fun reader(key: TestKey): Flow = + when (++readerCalls) { + 1 -> flow { emit(null) } + 2 -> + flow { + sharedReaderStarted.complete(Unit) + emitAll(liveRows) + } + + else -> { + check(directSnapshotPrepared) { "unexpected reader invocation $readerCalls" } + directSnapshotPrepared = false + val snapshot = nextDirectSnapshot + flow { + directHydrationStarted.complete(Unit) + releaseDirectHydration.await() + emit(snapshot) + } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + publish(value) + } + + override suspend fun delete(key: TestKey) { + publish(null) + } + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthSubstitutionTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthSubstitutionTest.kt new file mode 100644 index 000000000..13935ab8f --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthSubstitutionTest.kt @@ -0,0 +1,188 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, +) + +package org.mobilenativefoundation.store6.core + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import org.mobilenativefoundation.store6.core.seam.WallClock + +/** Installs an alternate source of truth without changing public Store conformance scenarios. */ +abstract class SourceOfTruthSubstitutionTest { + private var currentReaderProbe: ReaderDeliveryProbeSourceOfTruth<*, *>? = null + + protected open fun installSot(builder: StoreBuilder) { + builder.persistence( + trackedSot(defaultConformanceSourceOfTruth()), + ) + } + + /** Wraps a test SoT so scheduler-sensitive scenarios can await a real downstream delivery. */ + protected fun trackedSot( + delegate: SourceOfTruth, + ): SourceOfTruth = + ReaderDeliveryProbeSourceOfTruth(delegate).also { currentReaderProbe = it } + + /** + * Records the latest started reader collection before a direct key clear. The matching await + * must then observe a later collection's completed engine-facing delivery. + */ + protected suspend fun prepareNextReaderDelivery(key: StoreKey) { + checkNotNull(currentReaderProbe) { + "The substitution must install its SourceOfTruth through trackedSot()." + }.prepareNextReaderDelivery(key) + } + + /** + * Test-fixture acknowledgement completed only after the selected reader value returns from the + * engine-facing emit. The public LocalOnly baseline starts demand; this closes the downstream + * raw-stamping edge before a fetch or destructive mutation is released. + */ + protected open suspend fun awaitCurrentReaderFirstDelivery(key: StoreKey) { + checkNotNull(currentReaderProbe) { + "The substitution must install its SourceOfTruth through trackedSot()." + }.awaitCurrentReaderFirstDelivery(key) + } + + protected fun testStore( + configure: StoreBuilder.() -> Unit, + ): Store = + store { + configure() + installSot(this) + } + + /** Preserves injected test seams while routing the Store through the same substitution hook. */ + internal fun testStoreWith( + clock: WallClock? = null, + bookkeeper: Bookkeeper? = null, + configure: StoreBuilder.() -> Unit, + ): Store = + storeWith(clock = clock, bookkeeper = bookkeeper) { + configure() + installSot(this) + } +} + +/** No-yield acknowledgement decorator; adversarial decorators may remain nested inside it. */ +internal class ReaderDeliveryProbeSourceOfTruth( + private val delegate: SourceOfTruth, +) : SourceOfTruth { + private val deliveries = ConformanceReaderDeliveries() + + override fun reader(key: K): Flow = + flow { + val sequence = deliveries.begin(key) + delegate.reader(key).collect { value -> + emit(value) + deliveries.complete(key, sequence) + } + } + + override suspend fun write( + key: K, + value: V, + ) { + delegate.write(key, value) + } + + override suspend fun delete(key: K) { + delegate.delete(key) + } + + override suspend fun deleteNamespace(namespace: StoreNamespace) { + delegate.deleteNamespace(namespace) + } + + override suspend fun deleteAll() { + delegate.deleteAll() + } + + suspend fun prepareNextReaderDelivery(key: StoreKey) { + deliveries.prepareNext(key) + } + + suspend fun awaitCurrentReaderFirstDelivery(key: StoreKey) { + // Callers own cancellation through their suite-level runTest bound. + withContext(Dispatchers.Default) { + deliveries.awaitCurrent(key) + } + } +} + +private class ConformanceReaderDeliveries { + private val lock = Mutex() + private val current = HashMap() + private val preparedFloors = HashMap() + + suspend fun begin(key: StoreKey): Long = + lock.withLock { + val deliveries = deliveriesFor(key) + deliveries.startedSequence += 1L + deliveries.startedSequence + } + + suspend fun complete( + key: StoreKey, + sequence: Long, + ) { + lock.withLock { + val deliveries = deliveriesFor(key) + if (sequence > deliveries.completedSequence.value) { + deliveries.completedSequence.value = sequence + } + } + } + + suspend fun prepareNext(key: StoreKey) { + lock.withLock { + val identity = ConformanceKey.from(key) + preparedFloors[identity] = deliveriesFor(identity).startedSequence + } + } + + suspend fun awaitCurrent(key: StoreKey) { + val (completedSequence, floor) = + lock.withLock { + val identity = ConformanceKey.from(key) + deliveriesFor(identity).completedSequence to + (preparedFloors.remove(identity) ?: 0L) + } + completedSequence.first { sequence -> sequence > floor } + } + + private fun deliveriesFor(key: StoreKey): ConformanceReaderDeliveryState = + deliveriesFor(ConformanceKey.from(key)) + + private fun deliveriesFor(key: ConformanceKey): ConformanceReaderDeliveryState = + current.getOrPut(key) { ConformanceReaderDeliveryState() } +} + +private class ConformanceReaderDeliveryState( + var startedSequence: Long = 0L, + val completedSequence: MutableStateFlow = MutableStateFlow(0L), +) + +internal data class ConformanceKey( + val namespace: String, + val canonicalId: String, +) { + companion object { + fun from(key: StoreKey): ConformanceKey = + ConformanceKey( + namespace = key.namespace.value, + canonicalId = key.canonicalId(), + ) + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreBackpressureConformanceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreBackpressureConformanceTest.kt new file mode 100644 index 000000000..295e639e0 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreBackpressureConformanceTest.kt @@ -0,0 +1,170 @@ +package org.mobilenativefoundation.store6.core + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +class StoreBackpressureConformanceTest { + @Test + fun slowCollector_doesNotBlockFastCollector_orEngine_andIsBoundedPerCycle() = + runTest(timeout = 60.seconds) { + val key = TestKey("backpressure") + val slowGate = CompletableDeferred() + val slowParked = CompletableDeferred() + val slowLatestSerial = MutableStateFlow(0) + val fastLatestSerial = MutableStateFlow(0) + var slowDeliveries = 0 + val fetchSerial = MutableStateFlow(0) + val store = + store { + fetcher { + val next = fetchSerial.value + 1 + fetchSerial.value = next + "v$next" + } + } + val slowCollector = + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + store.stream(key).collect { result -> + result.throwIfError() + slowDeliveries += 1 + slowParked.complete(Unit) + slowGate.await() + if (result is StoreResult.Data && !result.isStale && !result.refreshing) { + slowLatestSerial.value = result.value.removePrefix("v").toInt() + } + } + } + + var fastCollector = backgroundScope.launch { } + try { + slowParked.await() + assertTrue(!slowGate.isCompleted, "slow collector must be parked before cycles") + fastCollector = + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + store.stream(key).collect { result -> + result.throwIfError() + if (result is StoreResult.Data && !result.isStale && !result.refreshing) { + fastLatestSerial.value = result.value.removePrefix("v").toInt() + } + } + } + awaitUntil { fastLatestSerial.value >= 1 } + + repeat(CYCLES) { + val before = fastLatestSerial.value + store.invalidate(key) + awaitUntil { fastLatestSerial.value > before } + } + awaitUntil { fastLatestSerial.value == fetchSerial.value } + val finalSerial = fetchSerial.value + val finalValue = "v$finalSerial" + assertEquals(finalSerial, fastLatestSerial.value) + assertEquals(finalValue, store.get(key, Freshness.LocalOnly)) + + slowGate.complete(Unit) + awaitUntil { slowLatestSerial.value == finalSerial } + slowCollector.cancelAndJoin() + assertTrue( + slowDeliveries <= MAX_BOUNDED_DELIVERIES, + // The operator unit test pins its internal pending queue to <= 4. This public + // engine-level bound includes the lifecycle deliveries around each cycle. + "slow collector received $slowDeliveries items for $CYCLES cycles", + ) + assertEquals(finalSerial, slowLatestSerial.value) + } finally { + slowGate.complete(Unit) + slowCollector.cancelAndJoin() + fastCollector.cancelAndJoin() + store.close() + } + } + + @Test + fun everyCollector_eventuallyObservesLatestRow() = runTest(timeout = 60.seconds) { + val key = TestKey("eventual") + val delayedGate = CompletableDeferred() + val delayedParked = CompletableDeferred() + val delayedLatest = MutableStateFlow(null) + val fastLatest = MutableStateFlow(null) + var serial = 0 + val fetchFinal = MutableStateFlow(false) + val store = store { + fetcher { if (fetchFinal.value) "final" else "v${++serial}" } + } + val finalValue = "final" + val delayedCollector = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.stream(key) + .onEach { result -> + result.throwIfError() + delayedParked.complete(Unit) + delayedGate.await() + } + .filterIsInstance>() + .map { it.value } + .onEach { delayedLatest.value = it } + .first { it == finalValue } + } + + val fastCollector = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.stream(key) + .onEach { result -> result.throwIfError() } + .filterIsInstance>() + .map { it.value } + .onEach { fastLatest.value = it } + .first { it == finalValue } + } + + try { + delayedParked.await() + assertTrue(!delayedGate.isCompleted, "delayed collector must be gated before cycles") + awaitUntil { fastLatest.value != null } + repeat(EVENTUAL_CYCLES) { + val before = fastLatest.value + store.invalidate(key) + awaitUntil { fastLatest.value != before } + } + fetchFinal.value = true + store.invalidate(key) + + awaitUntil(timeout = 5.seconds) { fastLatest.value == finalValue } + assertEquals(finalValue, fastCollector.await()) + assertEquals(finalValue, store.get(key, Freshness.LocalOnly)) + delayedGate.complete(Unit) + awaitUntil(timeout = 5.seconds) { delayedLatest.value == finalValue } + assertEquals(finalValue, delayedCollector.await()) + } finally { + delayedGate.complete(Unit) + delayedCollector.cancelAndJoin() + fastCollector.cancelAndJoin() + store.close() + } + } + + private fun StoreResult<*>.throwIfError() { + if (this is StoreResult.Error) { + throw AssertionError("unexpected Store error: $error") + } + } + + private companion object { + const val CYCLES = 25 + const val EVENTUAL_CYCLES = 10 + const val MAX_BOUNDED_DELIVERIES = 12 + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreBuilderPersistenceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreBuilderPersistenceTest.kt new file mode 100644 index 000000000..d2806e801 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreBuilderPersistenceTest.kt @@ -0,0 +1,48 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.test +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.internal.InMemorySourceOfTruth +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalStoreApi::class) +class StoreBuilderPersistenceTest { + @Test + fun persistenceRegistration_wiresFetchedWritesIntoTheSelectedSourceOfTruth() = runTest { + val sourceOfTruth = InMemorySourceOfTruth() + val store = store { + persistence(sourceOfTruth) + fetcher { "fetched" } + } + + assertEquals("fetched", store.get(TestKey("key"))) + sourceOfTruth.reader(TestKey("key")).test { + assertEquals("fetched", awaitItem()) + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + @Test + fun prepopulatedPersistence_localOnlyGetHydratesWithoutFetching() = runTest { + val sourceOfTruth = InMemorySourceOfTruth() + val key = TestKey("key") + sourceOfTruth.write(key, "durable") + var fetchCalls = 0 + val store = store { + persistence(sourceOfTruth) + fetcher { + fetchCalls += 1 + "network" + } + } + + try { + assertEquals("durable", store.get(key, Freshness.LocalOnly)) + assertEquals(0, fetchCalls) + } finally { + store.close() + } + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreCloseLifecycleTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreCloseLifecycleTest.kt new file mode 100644 index 000000000..dddd6d41f --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreCloseLifecycleTest.kt @@ -0,0 +1,119 @@ +package org.mobilenativefoundation.store6.core + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import org.mobilenativefoundation.store6.core.internal.RealStore +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.time.Duration.Companion.seconds + +class StoreCloseLifecycleTest { + @Test + fun close_cancelsCollectorsAndFetches_releasesRegistry_leakChecked() = + runTest(timeout = 60.seconds) { + val key = TestKey("close-active-work") + val fetchGate = CompletableDeferred() + val fetchStarted = CompletableDeferred() + val firstFrame = CompletableDeferred() + val store = + store { + fetcher { + fetchStarted.complete(Unit) + fetchGate.await() + "value" + } + } as RealStore + val waiter = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { store.get(key, Freshness.MustBeFresh) } + } + var collector: Deferred>? = null + + try { + fetchStarted.await() + val activeCollector = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { + store.stream(key).collect { + firstFrame.complete(Unit) + } + } + } + collector = activeCollector + firstFrame.await() + + store.close() + + val collectorFailure = + assertIs(activeCollector.await().exceptionOrNull()) + assertEquals("Store is closed.", collectorFailure.message) + val waiterFailure = + assertIs(waiter.await().exceptionOrNull()) + assertEquals("Store is closed.", waiterFailure.message) + store.awaitTerminationForTest() + assertEquals(0, store.residentEngineCountForTest()) + } finally { + withContext(NonCancellable) { + fetchGate.complete(Unit) + waiter.cancelAndJoin() + collector?.cancelAndJoin() + store.close() + } + } + } + + @Test + fun postClose_everyOperationFailsFast_withExactMessage() = runTest(timeout = 60.seconds) { + val key = TestKey("post-close") + val namespace = StoreNamespace("test") + val store = store { fetcher { "value" } } + val preCloseStream = store.stream(key) + + store.close() + store.close() + + assertStoreClosed { store.get(key) } + assertStoreClosed { store.stream(key) } + assertStoreClosed { preCloseStream.collect() } + assertStoreClosed { store.invalidate(key) } + assertStoreClosed { store.invalidateNamespace(namespace) } + assertStoreClosed { store.invalidateAll() } + assertStoreClosed { store.clear(key) } + assertStoreClosed { store.clearNamespace(namespace) } + assertStoreClosed { store.clearAll() } + } + + @Test + fun repeatedOpenCloseCycles_leaveNoResidentState() = runTest(timeout = 60.seconds) { + repeat(50) { cycle -> + val key = TestKey("cycle-$cycle") + val store = + store { + fetcher { "value-$cycle" } + } as RealStore + + try { + assertEquals("value-$cycle", store.get(key)) + } finally { + store.close() + store.awaitTerminationForTest() + } + assertEquals(0, store.residentEngineCountForTest()) + } + } + + private suspend fun assertStoreClosed(operation: suspend () -> Unit) { + val failure = assertFailsWith { operation() } + assertEquals("Store is closed.", failure.message) + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConfigSeamsTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConfigSeamsTest.kt new file mode 100644 index 000000000..f7ed6c8a5 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConfigSeamsTest.kt @@ -0,0 +1,122 @@ +package org.mobilenativefoundation.store6.core + +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.internal.InMemoryBookkeeper +import org.mobilenativefoundation.store6.core.internal.InMemorySourceOfTruth +import org.mobilenativefoundation.store6.core.seam.FetchPlan +import org.mobilenativefoundation.store6.core.seam.FreshnessContext +import org.mobilenativefoundation.store6.core.seam.FreshnessValidator +import org.mobilenativefoundation.store6.core.seam.WallClock +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) +class StoreConfigSeamsTest { + private class FixedWallClock(var now: Long) : WallClock { + override fun nowEpochMillis(): Long = now + } + + @Test + fun wallClock_drivesMaxAgePlanning() = runTest { + var fetches = 0 + val clock = FixedWallClock(now = 1_000L) + val store = + store { + fetcher { + fetches++ + "v$fetches" + } + wallClock(clock) + } + + assertEquals("v1", store.get(TestKey("1"), Freshness.MaxAge(5.seconds))) + assertEquals("v1", store.get(TestKey("1"), Freshness.MaxAge(5.seconds))) + assertEquals(1, fetches) + clock.now = 7_000L + assertEquals("v2", store.get(TestKey("1"), Freshness.MaxAge(5.seconds))) + assertEquals(2, fetches) + store.close() + } + + @Test + fun freshnessValidator_alwaysSkip_neverInvokesFetcher() = runTest { + var fetches = 0 + val store = + store { + fetcher { + fetches++ + "v" + } + freshnessValidator( + object : FreshnessValidator { + override fun plan(context: FreshnessContext): FetchPlan = FetchPlan.Skip + }, + ) + } + + val failure = assertFailsWith { store.get(TestKey("1")) } + assertIs(failure.error) + assertEquals(0, fetches) + store.close() + } + + @Test + fun freshnessValidator_alwaysFetch_refetchesEveryGet() = runTest { + var fetches = 0 + val store = + store { + fetcher { + fetches++ + "v$fetches" + } + freshnessValidator( + object : FreshnessValidator { + override fun plan(context: FreshnessContext): FetchPlan = + FetchPlan.Fetch(servesResidentWhileFetching = false) + }, + ) + } + + assertEquals("v1", store.get(TestKey("1"))) + assertEquals("v2", store.get(TestKey("1"))) + assertEquals(2, fetches) + store.close() + } + + @Test + fun bookkeeper_sharedAcrossRestart_preservesDurableStaleness() = runTest { + var fetches = 0 + val sharedBookkeeper = InMemoryBookkeeper() + val sharedSot = InMemorySourceOfTruth() + val first = + store { + fetcher { + fetches++ + "v$fetches" + } + persistence(sharedSot) + bookkeeper(sharedBookkeeper) + } + + assertEquals("v1", first.get(TestKey("1"))) + first.invalidate(TestKey("1")) + first.close() + + val second = + store { + fetcher { + fetches++ + "v$fetches" + } + persistence(sharedSot) + bookkeeper(sharedBookkeeper) + } + + assertEquals("v2", second.get(TestKey("1"), Freshness.MaxAge(5.seconds))) + assertEquals(2, fetches) + second.close() + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConformanceSubstitutionRuns.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConformanceSubstitutionRuns.kt new file mode 100644 index 000000000..47d82ff52 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConformanceSubstitutionRuns.kt @@ -0,0 +1,60 @@ +package org.mobilenativefoundation.store6.core + +import org.mobilenativefoundation.store6.core.internal.SharedFlowSourceOfTruth +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth + +@OptIn(ExperimentalStoreApi::class) +class StoreConformanceAgainstSharedFlowSotTest : StoreConformanceTest() { + override fun installSot(builder: StoreBuilder) { + builder.persistence(SharedFlowSourceOfTruth()) + } +} + +@OptIn(ExperimentalStoreApi::class) +class StoreInvalidationConformanceAgainstSharedFlowSotTest : StoreInvalidationConformanceTest() { + override fun installSot(builder: StoreBuilder) { + builder.persistence(trackedSot(SharedFlowSourceOfTruth())) + } +} + +@OptIn(ExperimentalStoreApi::class) +class EmissionSequenceConformanceAgainstSharedFlowSotTest : EmissionSequenceConformanceTest() { + override fun installSot(builder: StoreBuilder) { + builder.persistence(trackedSot(SharedFlowSourceOfTruth())) + } +} + +@OptIn(ExperimentalStoreApi::class) +class SingleFlightConformanceAgainstSharedFlowSotTest : SingleFlightConformanceTest() { + override fun installSot(builder: StoreBuilder) { + builder.persistence(SharedFlowSourceOfTruth()) + } +} + +@OptIn(ExperimentalStoreApi::class) +class FreshnessPolicyConformanceAgainstSharedFlowSotTest : FreshnessPolicyConformanceTest() { + override fun installSot(builder: StoreBuilder) { + builder.persistence(trackedSot(SharedFlowSourceOfTruth())) + } +} + +@OptIn(ExperimentalStoreApi::class) +class StoreRevalidationConformanceTestAgainstSharedFlowSot : StoreRevalidationConformance() { + override fun installSot(builder: StoreBuilder) { + builder.persistence(SharedFlowSourceOfTruth()) + } +} + +@OptIn(ExperimentalStoreApi::class) +class StoreDurableMaintenanceConformanceAgainstSharedFlowSotTest : + StoreDurableMaintenanceConformance() { + override fun createSourceOfTruth(): SourceOfTruth = + SharedFlowSourceOfTruth() +} + +@OptIn(ExperimentalStoreApi::class) +class StoreInvalidationStressAgainstSharedFlowSotTest : StoreInvalidationStressConformance() { + override fun installSot(builder: StoreBuilder) { + builder.persistence(SharedFlowSourceOfTruth()) + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConformanceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConformanceTest.kt new file mode 100644 index 000000000..7cc8a555d --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConformanceTest.kt @@ -0,0 +1,277 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.test +import app.cash.turbine.turbineScope +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.time.Duration.Companion.seconds + +open class StoreConformanceTest : SourceOfTruthSubstitutionTest() { + + // (a) the cold-stream acceptance test: Loading then Data(origin=FETCHER) + @Test + fun coldStream_noCachedValue_emitsLoadingThenDataFromFetcher() = runTest { + val store = testStore { fetcher { "value-for-${it.canonicalId()}" } } + store.stream(TestKey("1")).test { + assertIs(awaitItem()) + val data = assertIs>(awaitItem()) + assertEquals("value-for-1", data.value) + assertEquals(Origin.FETCHER, data.origin) + expectNoEvents() // live, not completed + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + // (b1) fetcher throws -> stream emits Error and stays live (the stream never throws) + @Test + fun fetcherThrows_streamEmitsErrorAndStaysLive() = runTest { + val store = testStore { fetcher { throw IllegalStateException("boom") } } + store.stream(TestKey("1")).test { + assertIs(awaitItem()) + val error = assertIs(awaitItem()) + assertIs(error.error) + expectNoEvents() // Error did not terminate the flow + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + // (b2) fetcher throws -> get throws StoreException carrying StoreError.Fetch + @Test + fun fetcherThrows_getThrowsStoreException() = runTest { + val store = testStore { fetcher { throw IllegalStateException("boom") } } + val ex = assertFailsWith { store.get(TestKey("1")) } + assertIs(ex.error) + store.close() + } + + // (c) single-flight smoke: two concurrent collectors, one fetcher invocation + @Test + fun twoConcurrentCollectors_singleFetcherInvocation() = runTest { + var calls = 0 + val gate = CompletableDeferred() + val store = testStore { + fetcher { + calls++ + gate.await() + "v" + } + } + turbineScope { + val a = store.stream(TestKey("1")).testIn(backgroundScope) + val b = store.stream(TestKey("1")).testIn(backgroundScope) + assertIs(a.awaitItem()) + assertIs(b.awaitItem()) // both subscribed before the fetch resolves + gate.complete(Unit) + assertEquals("v", assertIs>(a.awaitItem()).value) + assertEquals("v", assertIs>(b.awaitItem()).value) + assertEquals(1, calls) + } + store.close() + } + + // (d) a resident value is served without a refetch + @Test + fun getAfterStreamCommitted_servesResidentValueWithoutRefetch() = runTest { + var calls = 0 + val store = testStore { + fetcher { + calls++ + "v$calls" + } + } + store.stream(TestKey("1")).test { + assertIs(awaitItem()) + assertIs>(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + assertEquals("v1", store.get(TestKey("1"))) // resident value served + assertEquals(1, calls) // no second fetch + store.close() + } + + // (e) pins replay semantics: a late collector gets Data immediately, never a spurious Loading + @Test + fun lateCollectorAfterData_receivesDataImmediately() = runTest { + val store = testStore { fetcher { "v" } } + assertEquals("v", store.get(TestKey("1"))) // commits residence + store.stream(TestKey("1")).test { + val first = assertIs>(awaitItem()) // no Loading first + assertEquals("v", first.value) + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + @Test + fun closeDuringFetch_getWaiterTerminatesPromptly() = runTest { + val started = CompletableDeferred() + val gate = CompletableDeferred() + val store = testStore { + fetcher { + started.complete(Unit) + gate.await() + "v" + } + } + val waiter = backgroundScope.async { runCatching { store.get(TestKey("1")) } } + started.await() + + store.close() + + val failure = + withContext(Dispatchers.Default) { + waiter.await() + }.exceptionOrNull() + assertIs(failure) + } + + @Test + fun closeDuringFetch_streamCollectorTerminatesPromptly() = runTest { + val started = CompletableDeferred() + val gate = CompletableDeferred() + val store = testStore { + fetcher { + started.complete(Unit) + gate.await() + "v" + } + } + val collector = backgroundScope.async { + runCatching { store.stream(TestKey("1")).collect() } + } + started.await() + + store.close() + + val failure = + withContext(Dispatchers.Default) { + collector.await() + }.exceptionOrNull() + assertIs(failure) + } + + @Test + fun getAfterClose_failsFastWithDeterministicException() = runTest { + val store = testStore { fetcher { "v" } } + store.close() + + val failure = assertFailsWith { + withTimeout(1_000) { store.get(TestKey("1")) } + } + + assertEquals("Store is closed.", failure.message) + } + + @Test + fun streamAfterClose_failsFastWithDeterministicException() = runTest { + val store = testStore { fetcher { "v" } } + store.close() + + val failure = assertFailsWith { + store.stream(TestKey("1")) + } + + assertEquals("Store is closed.", failure.message) + } + + @Test + fun streamCreatedBeforeClose_failsFastWhenCollectedAfterClose() = runTest { + val store = testStore { fetcher { "v" } } + val stream = store.stream(TestKey("1")) + store.close() + + val failure = assertFailsWith { + withTimeout(1_000) { stream.collect() } + } + + assertEquals("Store is closed.", failure.message) + } + + @Test + fun nonCooperativeFetcher_closeTerminatesGetBeforeFetcherReleases() = runTest { + val started = CompletableDeferred() + val release = CompletableDeferred() + val store = testStore { + fetcher { + started.complete(Unit) + withContext(NonCancellable) { release.await() } + "v" + } + } + val waiter = backgroundScope.async { runCatching { store.get(TestKey("1")) } } + + try { + started.await() + store.close() + + val failure = + withContext(Dispatchers.Default) { + waiter.await() + }.exceptionOrNull() + assertFalse(release.isCompleted) + assertIs(failure) + } finally { + release.complete(Unit) + store.close() + } + } + + @Test + fun nonCooperativeFetcher_closeTerminatesStreamBeforeFetcherReleases() = runTest { + val started = CompletableDeferred() + val release = CompletableDeferred() + val store = testStore { + fetcher { + started.complete(Unit) + withContext(NonCancellable) { release.await() } + "v" + } + } + val collector = backgroundScope.async { + runCatching { store.stream(TestKey("1")).collect() } + } + + try { + started.await() + store.close() + + val failure = + withContext(Dispatchers.Default) { + collector.await() + }.exceptionOrNull() + assertFalse(release.isCompleted) + assertIs(failure) + } finally { + release.complete(Unit) + store.close() + } + } +} + +// Turbine's 3s default would nest inside the 25s shadow. Raising the Turbine deadline above +// the shadow makes runTest the only effective timeout. +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds // strictly > TEST_TIMEOUT: the shadow must fire first + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreDefaultsPinTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreDefaultsPinTest.kt new file mode 100644 index 000000000..575a78f02 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreDefaultsPinTest.kt @@ -0,0 +1,144 @@ +package org.mobilenativefoundation.store6.core + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.time.Duration.Companion.seconds + +/** + * Pins the two zero-config defaults the published Important Defaults page states but that no other + * conformance test names as *the default*: the default [Freshness] and the absence of fetcher + * retries. Both lines are public documentation, so both get a test that fails when the + * documentation stops being true. + */ +@OptIn(ExperimentalStoreApi::class) +class StoreDefaultsPinTest { + /** + * The default [Freshness] is [Freshness.CachedOrFetch]. Two observations distinguish it from + * the alternatives: an absent key fetches (so the default is not [Freshness.LocalOnly]), and a + * resident fresh value is served without a second fetch (so it is not + * [Freshness.MustBeFresh]). + */ + @Test + fun defaultFreshness_isCachedOrFetch_zeroConfig() = + runTest(timeout = 60.seconds) { + val fetcher = CountingFetcher() + val store = + store { + fetcher(fetcher::fetch) + } + + try { + val key = TestKey("default-freshness") + + assertEquals("v1:default-freshness", store.get(key)) + assertEquals(1, fetcher.count, "an absent key must fetch: the default is not LocalOnly") + + assertEquals("v1:default-freshness", store.get(key)) + assertEquals( + 1, + fetcher.count, + "a resident fresh value must be served without a second fetch: " + + "the default is not MustBeFresh", + ) + } finally { + store.close() + } + } + + /** + * The engine never retries your fetcher. One demand cycle invokes it exactly once, a failure + * schedules no background retry and no backoff, and a later call is a new demand rather than a + * continuation of the failed one. + * + * The quiet windows below run on [Dispatchers.Default] in real time on purpose: the engine's + * own scope is `Dispatchers.Default` ([RealStore]), so it never observes `runTest`'s virtual + * clock and a virtual-time advance would prove nothing. [NO_RETRY_WINDOW_MILLIS] is an order of + * magnitude above the engine's internal fixed-delay scale, so a retry with backoff would have + * fired inside it. + */ + @Test + fun fetcherFailure_isNotRetried_zeroConfig() = + runTest(timeout = 60.seconds) { + val fetcher = AlwaysFailingFetcher() + val store = + store { + fetcher(fetcher::fetch) + } + + try { + val key = TestKey("no-retry") + + // A terminalizing demand cycle: one call, one invocation. + assertFailsWith { store.get(key, Freshness.MustBeFresh) } + assertEquals(1, fetcher.count, "one demand cycle invokes the fetcher exactly once") + + withContext(Dispatchers.Default) { delay(NO_RETRY_WINDOW_MILLIS) } + assertEquals(1, fetcher.count, "a failed fetch schedules no background retry") + + // A second call is new demand, not a continuation of the failed one. + assertFailsWith { store.get(key, Freshness.MustBeFresh) } + assertEquals(2, fetcher.count, "a second call is a new demand, not a retry") + + // The non-terminalizing path: a stream collector survives a fetch failure and stays + // live. This is where a background retry could hide, so it gets its own window. + val streamKey = TestKey("no-retry-stream") + val collector = + launch(Dispatchers.Default) { + store.stream(streamKey).collect { } + } + try { + withContext(Dispatchers.Default) { + while (fetcher.count < 3) { + delay(POLL_MILLIS) + } + delay(NO_RETRY_WINDOW_MILLIS) + } + assertEquals( + 3, + fetcher.count, + "a live collector whose fetch failed triggers no background retry either", + ) + } finally { + collector.cancelAndJoin() + } + } finally { + store.close() + } + } + + private class CountingFetcher { + var count: Int = 0 + private set + + fun fetch(key: TestKey): String { + count++ + return "v$count:${key.canonicalId()}" + } + } + + private class AlwaysFailingFetcher { + var count: Int = 0 + private set + + fun fetch(key: TestKey): String { + count++ + throw IllegalStateException("fetch failed for ${key.canonicalId()}") + } + } + + private companion object { + /** + * A real-time quiet window, an order of magnitude above the engine's internal fixed-delay + * scale (`READER_RETRY_DELAY_MILLIS = 100L`), so any retry-with-backoff would fire inside it. + */ + const val NO_RETRY_WINDOW_MILLIS = 1_000L + const val POLL_MILLIS = 10L + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreDurableMaintenanceConformanceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreDurableMaintenanceConformanceTest.kt new file mode 100644 index 000000000..39fed9a51 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreDurableMaintenanceConformanceTest.kt @@ -0,0 +1,147 @@ +package org.mobilenativefoundation.store6.core + +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.internal.InMemoryBookkeeper +import org.mobilenativefoundation.store6.core.internal.InMemorySourceOfTruth +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.hours + +/** + * Simulates restart by constructing a fresh Store around the same Bookkeeper and SourceOfTruth. + * This proves store-instance-independent durable facts, not on-disk durability, which the + * persistence adapter modules cover. + */ +@OptIn(ExperimentalStoreApi::class) +abstract class StoreDurableMaintenanceConformance { + protected abstract fun createSourceOfTruth(): SourceOfTruth + + @Test + fun invalidate_markIsObservedByFreshStoreUsingSharedCollaborators() = runTest { + var calls = 0 + val sharedBookkeeper = InMemoryBookkeeper() + val sharedSot = createSourceOfTruth() + val clock = FakeWallClock(now = 100L) + fun buildStore(): Store = + restartedStore(sharedBookkeeper, sharedSot, clock) { "v${++calls}" } + + val first = buildStore() + try { + assertEquals("v1", first.get(TestKey("1"))) + first.invalidate(TestKey("1")) + } finally { + first.close() + } + + val second = buildStore() + try { + assertEquals("v2", second.get(TestKey("1"), Freshness.MaxAge(1.hours))) + assertEquals(2, calls) + } finally { + second.close() + } + } + + @Test + fun invalidateNamespace_watermarkIsObservedForKeyUnseenByFreshStore() = runTest { + var calls = 0 + val sharedBookkeeper = InMemoryBookkeeper() + val sharedSot = createSourceOfTruth() + val clock = FakeWallClock(now = 100L) + fun buildStore(): Store = + restartedStore(sharedBookkeeper, sharedSot, clock) { "v${++calls}" } + val keyA1 = NamespacedTestKey("a", "1") + val keyA2 = NamespacedTestKey("a", "2") + + val first = buildStore() + try { + assertEquals("v1", first.get(keyA1)) + assertEquals("v2", first.get(keyA2)) + first.invalidateNamespace(StoreNamespace("a")) + } finally { + first.close() + } + + val second = buildStore() + try { + assertEquals("v3", second.get(keyA2, Freshness.MaxAge(1.hours))) + assertEquals(3, calls) + } finally { + second.close() + } + } + + @Test + fun invalidateAll_globalWatermarkIsObservedAcrossNamespacesByFreshStore() = runTest { + var calls = 0 + val sharedBookkeeper = InMemoryBookkeeper() + val sharedSot = createSourceOfTruth() + val clock = FakeWallClock(now = 100L) + fun buildStore(): Store = + restartedStore(sharedBookkeeper, sharedSot, clock) { "v${++calls}" } + val keyA = NamespacedTestKey("a", "1") + val keyB = NamespacedTestKey("b", "1") + + val first = buildStore() + try { + assertEquals("v1", first.get(keyA)) + assertEquals("v2", first.get(keyB)) + first.invalidateAll() + } finally { + first.close() + } + + val second = buildStore() + try { + assertEquals("v3", second.get(keyA, Freshness.MaxAge(1.hours))) + assertEquals("v4", second.get(keyB, Freshness.MaxAge(1.hours))) + assertEquals(4, calls) + } finally { + second.close() + } + } + + @Test + fun freshSidecarWithoutInvalidation_servesHydratedValueAfterFreshStoreStarts() = runTest { + var calls = 0 + val sharedBookkeeper = InMemoryBookkeeper() + val sharedSot = createSourceOfTruth() + val clock = FakeWallClock(now = 100L) + fun buildStore(): Store = + restartedStore(sharedBookkeeper, sharedSot, clock) { "v${++calls}" } + + val first = buildStore() + try { + assertEquals("v1", first.get(TestKey("1"))) + } finally { + first.close() + } + + val second = buildStore() + try { + assertEquals("v1", second.get(TestKey("1"), Freshness.MaxAge(1.hours))) + assertEquals(1, calls) + } finally { + second.close() + } + } + + private fun restartedStore( + sharedBookkeeper: Bookkeeper, + sharedSot: SourceOfTruth, + clock: FakeWallClock, + fetch: suspend (K) -> V, + ): Store = + storeWith(clock = clock, bookkeeper = sharedBookkeeper) { + fetcher(fetch) + persistence(sharedSot) + } +} + +@OptIn(ExperimentalStoreApi::class) +class StoreDurableMaintenanceConformanceTest : StoreDurableMaintenanceConformance() { + override fun createSourceOfTruth(): SourceOfTruth = + InMemorySourceOfTruth() +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreDurableMaintenanceFailureTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreDurableMaintenanceFailureTest.kt new file mode 100644 index 000000000..1ce0be684 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreDurableMaintenanceFailureTest.kt @@ -0,0 +1,263 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.test +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.internal.InMemorySourceOfTruth +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +@OptIn(ExperimentalStoreApi::class) +class StoreDurableMaintenanceFailureTest { + @Test + fun invalidate_whenMarkPaused_doesNotSignalActiveStream_thenFailureIsTyped() = runTest { + val entered = kotlinx.coroutines.CompletableDeferred() + val release = kotlinx.coroutines.CompletableDeferred() + val expectedCause = IllegalStateException("mark unavailable") + val durableBookkeeper = + RecordingBookkeeper(markStaleFailure = expectedCause).also { + it.markEntered = entered + it.releaseMark = release + } + var calls = 0 + val store = store { + fetcher { "v${++calls}" } + bookkeeper(durableBookkeeper) + } + + store.stream(TestKey("1")).test { + assertIs(awaitItem()) + assertEquals("v1", assertIs>(awaitItem()).value) + val invalidation = backgroundScope.async { runCatching { store.invalidate(TestKey("1")) } } + entered.await() + try { + assertTrue(entered.isCompleted, "invalidate must enter durable mark before signaling") + expectNoEvents() + assertEquals(1, calls) + release.complete(Unit) + val failure = assertIs(invalidation.await().exceptionOrNull()) + assertPersistenceCause(expectedCause, failure) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } finally { + release.complete(Unit) + } + } + store.close() + } + + @Test + fun invalidateAll_whenGlobalWatermarkFails_isTypedAndDoesNotSignalActiveStream() = runTest { + val expectedCause = IllegalStateException("global watermark unavailable") + val durableBookkeeper = RecordingBookkeeper(advanceWatermarkFailure = expectedCause) + var calls = 0 + val store = store { + fetcher { "v${++calls}" } + bookkeeper(durableBookkeeper) + } + + store.stream(TestKey("1")).test { + assertIs(awaitItem()) + assertEquals("v1", assertIs>(awaitItem()).value) + val failure = assertFailsWith { store.invalidateAll() } + assertPersistenceCause(expectedCause, failure) + expectNoEvents() + assertEquals(1, calls) + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + @Test + fun invalidateNamespace_whenWatermarkAdvanceFails_isTypedAndDoesNotSignalActiveStream() = + runTest { + val expectedCause = IllegalStateException("namespace watermark unavailable") + val durableBookkeeper = RecordingBookkeeper(advanceWatermarkFailure = expectedCause) + var calls = 0 + val store = store { + fetcher { "v${++calls}" } + bookkeeper(durableBookkeeper) + } + + store.stream(TestKey("1")).test { + assertIs(awaitItem()) + assertEquals("v1", assertIs>(awaitItem()).value) + val failure = assertFailsWith { + store.invalidateNamespace(StoreNamespace("test")) + } + assertPersistenceCause(expectedCause, failure) + expectNoEvents() + assertEquals(1, calls) + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + @Test + fun clear_whenSotDeleteFails_isTypedLeavesRowAndResidenceAndDoesNotForget() = runTest { + val events = mutableListOf() + val expectedCause = IllegalStateException("key delete unavailable") + val durableBookkeeper = RecordingBookkeeper(events = events) + val backing = InMemorySourceOfTruth() + val durableSot = RecordingSourceOfTruth(backing, events) + val key = TestKey("1") + val store = store { + fetcher { "v1" } + persistence(durableSot) + bookkeeper(durableBookkeeper) + } + assertEquals("v1", store.get(key)) + durableSot.deleteFailure = expectedCause + + val failure = assertFailsWith { store.clear(key) } + assertPersistenceCause(expectedCause, failure) + assertEquals("v1", backing.reader(key).first()) + assertEquals("v1", store.get(key, Freshness.LocalOnly)) + assertTrue(events.none { it == "forget:test/1" }) + store.close() + } + + @Test + // Per-key forget remains operationally infallible; this fake is defensive boundary hardening. + fun clear_whenForgetViolatesContract_isTypedAfterDelete() = runTest { + val expectedCause = IllegalStateException("forget contract violation") + val durableBookkeeper = RecordingBookkeeper(forgetFailure = expectedCause) + val durableSot = InMemorySourceOfTruth() + val store = store { + fetcher { "v1" } + persistence(durableSot) + bookkeeper(durableBookkeeper) + } + val key = TestKey("1") + assertEquals("v1", store.get(key)) + + val failure = assertFailsWith { store.clear(key) } + assertPersistenceCause(expectedCause, failure) + assertNull(durableSot.reader(key).first(), "delete must remain applied before forget fails") + store.close() + } + + @Test + fun clearNamespace_whenForgetFails_isTypedAfterDeleteAndKeepsOtherNamespace() = runTest { + val events = mutableListOf() + val expectedCause = IllegalStateException("namespace forget unavailable") + val durableBookkeeper = + RecordingBookkeeper(events = events, forgetNamespaceFailure = expectedCause) + val backing = InMemorySourceOfTruth() + val durableSot = RecordingSourceOfTruth(backing, events) + val a = NamespacedTestKey("a", "1") + val b = NamespacedTestKey("b", "1") + val store = store { + fetcher { if (it.namespace.value == "a") "va" else "vb" } + persistence(durableSot) + bookkeeper(durableBookkeeper) + } + store.get(a) + store.get(b) + + val failure = assertFailsWith { + store.clearNamespace(StoreNamespace("a")) + } + assertPersistenceCause(expectedCause, failure) + assertNull(backing.reader(a).first()) + assertEquals("vb", backing.reader(b).first()) + assertTrue( + events.indexOf("deleteNamespace:a") in 0 until events.indexOf("forgetNamespace:a"), + ) + store.close() + } + + @Test + fun clearAll_whenForgetFails_isTypedAfterDeleteAndOrdersSteps() = runTest { + val events = mutableListOf() + val expectedCause = IllegalStateException("global forget unavailable") + val durableBookkeeper = RecordingBookkeeper(events = events, forgetAllFailure = expectedCause) + val backing = InMemorySourceOfTruth() + val durableSot = RecordingSourceOfTruth(backing, events) + val a = NamespacedTestKey("a", "1") + val b = NamespacedTestKey("b", "1") + val store = store { + fetcher { "v" } + persistence(durableSot) + bookkeeper(durableBookkeeper) + } + store.get(a) + store.get(b) + + val failure = assertFailsWith { store.clearAll() } + assertPersistenceCause(expectedCause, failure) + assertNull(backing.reader(a).first()) + assertNull(backing.reader(b).first()) + assertTrue(events.indexOf("deleteAll") in 0 until events.indexOf("forgetAll")) + store.close() + } + + @Test + fun clearNamespace_whenBulkDeleteFails_isTypedDoesNotForgetAndRowsRemain() = runTest { + val events = mutableListOf() + val expectedCause = IllegalStateException("namespace delete unavailable") + val durableBookkeeper = RecordingBookkeeper(events = events) + val backing = InMemorySourceOfTruth() + val durableSot = RecordingSourceOfTruth(backing, events) + val a = NamespacedTestKey("a", "1") + val b = NamespacedTestKey("b", "1") + val store = store { + fetcher { if (it.namespace.value == "a") "va" else "vb" } + persistence(durableSot) + bookkeeper(durableBookkeeper) + } + store.get(a) + store.get(b) + durableSot.deleteNamespaceFailure = expectedCause + + val failure = assertFailsWith { + store.clearNamespace(StoreNamespace("a")) + } + assertPersistenceCause(expectedCause, failure) + assertTrue(events.none { it == "forgetNamespace:a" }) + assertEquals("va", backing.reader(a).first()) + assertEquals("vb", backing.reader(b).first()) + store.close() + } + + @Test + fun clearAll_whenBulkDeleteFails_isTypedDoesNotForgetAndRowsRemain() = runTest { + val events = mutableListOf() + val expectedCause = IllegalStateException("global delete unavailable") + val durableBookkeeper = RecordingBookkeeper(events = events) + val backing = InMemorySourceOfTruth() + val durableSot = RecordingSourceOfTruth(backing, events) + val a = NamespacedTestKey("a", "1") + val b = NamespacedTestKey("b", "1") + val store = store { + fetcher { "v-${it.namespace.value}" } + persistence(durableSot) + bookkeeper(durableBookkeeper) + } + store.get(a) + store.get(b) + durableSot.deleteAllFailure = expectedCause + + val failure = assertFailsWith { store.clearAll() } + assertPersistenceCause(expectedCause, failure) + assertTrue(events.none { it == "forgetAll" }) + assertEquals("v-a", backing.reader(a).first()) + assertEquals("v-b", backing.reader(b).first()) + store.close() + } + + private fun assertPersistenceCause( + expectedCause: Throwable, + failure: StoreException, + ) { + val persistence = assertIs(failure.error) + assertSame(expectedCause, persistence.cause) + assertSame(expectedCause, failure.cause) + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreEvictionConformanceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreEvictionConformanceTest.kt new file mode 100644 index 000000000..df68a35e2 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreEvictionConformanceTest.kt @@ -0,0 +1,387 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.ReceiveTurbine +import app.cash.turbine.test +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.internal.InMemorySourceOfTruth +import org.mobilenativefoundation.store6.core.internal.RealStore +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalStoreApi::class) +class StoreEvictionConformanceTest { + @Test + fun evictedEngine_recreation_semanticallyInvisible() = runTest(timeout = 60.seconds) { + val key = TestKey("A") + val clock = FakeWallClock(now = 1_000L) + val secondFetchGate = CompletableDeferred() + val thirdFetchGate = CompletableDeferred() + var aFetches = 0 + val store = + storeWith(clock = clock) { + maxIdleKeys(4) + fetcher { requested -> + if (requested.canonicalId() != "A") { + "${requested.canonicalId()}-value" + } else { + when (++aFetches) { + 2 -> secondFetchGate.await() + 3 -> thirdFetchGate.await() + } + "A-v$aFetches" + } + } + } as RealStore + + try { + assertEquals("A-v1", store.get(key)) + clock.now += 1_000L + store.invalidate(key) + + val destroyedBeforeFirstChurn = store.destroyedEngineCountForTest() + repeat(9) { index -> store.get(TestKey("first-churn-$index")) } + awaitUntil { store.destroyedEngineCountForTest() > destroyedBeforeFirstChurn } + val createdBeforeFirstRecreation = store.createdEngineCountForTest() + + store.stream(key).test { + val stale = assertIs>(awaitItem()) + assertEquals("A-v1", stale.value) + assertTrue(stale.isStale) + assertTrue(stale.refreshing) + assertEquals( + createdBeforeFirstRecreation + 1L, + store.createdEngineCountForTest(), + "A must be recreated after leaving the idle LRU", + ) + + secondFetchGate.complete(Unit) + val fresh = awaitData("A-v2") + assertFalse(fresh.isStale) + assertFalse(fresh.refreshing) + cancelAndIgnoreRemainingEvents() + } + + clock.now += 1_000L + store.invalidateNamespace(StoreNamespace("test")) + val destroyedBeforeSecondChurn = store.destroyedEngineCountForTest() + repeat(9) { index -> store.get(TestKey("second-churn-$index")) } + awaitUntil { store.destroyedEngineCountForTest() > destroyedBeforeSecondChurn } + val createdBeforeSecondRecreation = store.createdEngineCountForTest() + + store.stream(key).test { + val stale = assertIs>(awaitItem()) + assertEquals("A-v2", stale.value) + assertTrue(stale.isStale) + assertTrue(stale.refreshing) + assertEquals( + createdBeforeSecondRecreation + 1L, + store.createdEngineCountForTest(), + "the namespace-invalidated A engine must be recreated", + ) + thirdFetchGate.complete(Unit) + awaitData("A-v3") + cancelAndIgnoreRemainingEvents() + } + } finally { + secondFetchGate.complete(Unit) + thirdFetchGate.complete(Unit) + store.close() + } + } + + @Test + fun memoryCache_neverDivergesFromDurableTruth() = runTest(timeout = 60.seconds) { + val key = TestKey("truth") + val sourceOfTruth = + CountingSourceOfTruth(InMemorySourceOfTruth()) + var fetches = 0 + val store = + store { + persistence(sourceOfTruth) + fetcher { "fetched-${++fetches}" } + } + + try { + assertEquals("fetched-1", store.get(key)) + assertDurableRowMatchesResidence(sourceOfTruth, store, key) + + store.invalidate(key) + assertDurableRowMatchesResidence(sourceOfTruth, store, key) + + assertEquals("fetched-2", store.get(key, Freshness.MustBeFresh)) + assertDurableRowMatchesResidence(sourceOfTruth, store, key) + + store.clear(key) + assertDurableRowMatchesResidence(sourceOfTruth, store, key) + + store.stream(key, Freshness.LocalOnly).test { + val missing = assertIs(awaitItem()) + assertIs(missing.error) + sourceOfTruth.write(key, "external") + assertEquals("external", awaitData("external").value) + assertDurableRowMatchesResidence(sourceOfTruth, store, key) + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun quiescentKeys_parkInIdle_boundedByMaxIdleKeys() = runTest(timeout = 60.seconds) { + val store = + store { + maxIdleKeys(8) + fetcher { "value-${it.canonicalId()}" } + } as RealStore + + try { + repeat(64) { index -> store.get(TestKey("idle-$index")) } + awaitUntil { + store.residentEngineCountForTest() == 8 && + store.idleEngineCountForTest() == 8 + } + + assertEquals(8, store.residentEngineCountForTest()) + assertEquals(8, store.idleEngineCountForTest()) + assertEquals(64L, store.createdEngineCountForTest()) + assertEquals(56L, store.destroyedEngineCountForTest()) + } finally { + store.close() + } + } + + @Test + fun activeCollector_pinsEngine_acrossChurn() = runTest(timeout = 60.seconds) { + val key = TestKey("A") + val firstData = CompletableDeferred() + val refreshedData = CompletableDeferred() + val collectorOutcome = CompletableDeferred() + var aFetches = 0 + val store = + store { + maxIdleKeys(2) + fetcher { requested -> + if (requested.canonicalId() == "A") { + "A-v${++aFetches}" + } else { + "${requested.canonicalId()}-value" + } + } + } as RealStore + val collector = + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + val outcome = + runCatching { + store.stream(key).collect { result -> + when (result) { + is StoreResult.Data -> { + if (result.value == "A-v1") firstData.complete(Unit) + if (result.value == "A-v2" && !result.isStale) { + refreshedData.complete(Unit) + } + } + is StoreResult.Error -> + throw AssertionError("unexpected Store error: ${result.error}") + is StoreResult.Loading, + is StoreResult.Revalidated, + -> Unit + } + } + } + collectorOutcome.complete(outcome.exceptionOrNull()) + } + + try { + firstData.await() + val destroyedBeforeChurn = store.destroyedEngineCountForTest() + repeat(100) { index -> store.get(TestKey("active-churn-$index")) } + awaitUntil { + val resident = store.residentEngineCountForTest() + store.destroyedEngineCountForTest() > destroyedBeforeChurn && + resident == 3 && + store.idleEngineCountForTest() == 2 && + store.createdEngineCountForTest() - store.destroyedEngineCountForTest() == + 3L + } + + assertTrue(collector.isActive) + assertFalse(collectorOutcome.isCompleted) + assertEquals(3, store.residentEngineCountForTest()) + assertEquals(2, store.idleEngineCountForTest()) + assertEquals( + 3L, + store.createdEngineCountForTest() - store.destroyedEngineCountForTest(), + ) + + store.invalidate(key) + refreshedData.await() + assertEquals(2, aFetches) + assertTrue(collector.isActive, "churn must not close-cancel a held engine") + assertFalse(collectorOutcome.isCompleted) + } finally { + collector.cancelAndJoin() + store.close() + } + } + + @Test + fun inFlightFetch_pinsEngine_acrossWaiterCancellation_andCommits() = + runTest(timeout = 60.seconds) { + val key = TestKey("A") + val fetchStarted = CompletableDeferred() + val fetchGate = CompletableDeferred() + val sourceOfTruth = InMemorySourceOfTruth() + var aFetches = 0 + val store = + store { + maxIdleKeys(2) + persistence(sourceOfTruth) + fetcher { requested -> + if (requested.canonicalId() == "A") { + aFetches += 1 + fetchStarted.complete(Unit) + fetchGate.await() + "A-v$aFetches" + } else { + "${requested.canonicalId()}-value" + } + } + } as RealStore + val waiter = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.get(key) + } + + try { + fetchStarted.await() + waiter.cancelAndJoin() + repeat(40) { index -> store.get(TestKey("fetch-churn-$index")) } + awaitUntil { + val resident = store.residentEngineCountForTest() + resident == 3 && + store.idleEngineCountForTest() == 2 && + store.createdEngineCountForTest() - store.destroyedEngineCountForTest() == + 3L + } + assertEquals(3, store.residentEngineCountForTest()) + assertEquals(2, store.idleEngineCountForTest()) + assertEquals( + 3L, + store.createdEngineCountForTest() - store.destroyedEngineCountForTest(), + ) + + fetchGate.complete(Unit) + awaitUntil { + sourceOfTruth.reader(key).first() == "A-v1" && + store.createdEngineCountForTest() - store.destroyedEngineCountForTest() == + store.residentEngineCountForTest().toLong() && + store.idleEngineCountForTest() == store.residentEngineCountForTest() + } + + assertEquals(1, aFetches) + assertEquals("A-v1", store.get(key)) + assertEquals(1, aFetches, "the committed value must be reused without refetch") + } finally { + fetchGate.complete(Unit) + waiter.cancelAndJoin() + store.close() + } + } + + private suspend fun ReceiveTurbine>.awaitData( + expected: String, + ): StoreResult.Data { + while (true) { + when (val result = awaitItem()) { + is StoreResult.Data -> if (result.value == expected) return result + is StoreResult.Error -> throw AssertionError("unexpected Store error: ${result.error}") + is StoreResult.Loading, + is StoreResult.Revalidated, + -> Unit + } + } + } + + private suspend fun assertDurableRowMatchesResidence( + sourceOfTruth: CountingSourceOfTruth, + store: Store, + key: TestKey, + ) { + val durableRow = sourceOfTruth.peek(key) + val readerCallsBeforeObservation = sourceOfTruth.readerCalls + if (durableRow == null) { + val failure = + assertFailsWith { + store.get(key, Freshness.LocalOnly) + } + assertIs(failure.error) + // With no resident envelope, LocalOnly must consult SoT to establish typed Missing. + // Reading absence cannot repair a divergence because there is no durable row to copy. + assertEquals( + readerCallsBeforeObservation + 1, + sourceOfTruth.readerCalls, + "absent residence must be confirmed by exactly one SourceOfTruth reader", + ) + } else { + assertEquals(durableRow, store.get(key, Freshness.LocalOnly)) + assertEquals( + readerCallsBeforeObservation, + sourceOfTruth.readerCalls, + "LocalOnly must not hydrate and mask a lost non-null residence", + ) + } + } + + @OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) + private class CountingSourceOfTruth( + private val delegate: SourceOfTruth, + ) : SourceOfTruth { + private val readerCallState = MutableStateFlow(0) + + val readerCalls: Int + get() = readerCallState.value + + override fun reader(key: K): Flow { + readerCallState.update { count -> count + 1 } + return delegate.reader(key) + } + + override suspend fun write( + key: K, + value: V, + ) { + delegate.write(key, value) + } + + override suspend fun delete(key: K) { + delegate.delete(key) + } + + override suspend fun deleteNamespace(namespace: StoreNamespace) { + delegate.deleteNamespace(namespace) + } + + override suspend fun deleteAll() { + delegate.deleteAll() + } + + suspend fun peek(key: K): V? = delegate.reader(key).first() + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreEvictionStressTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreEvictionStressTest.kt new file mode 100644 index 000000000..f7940976b --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreEvictionStressTest.kt @@ -0,0 +1,114 @@ +package org.mobilenativefoundation.store6.core + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import org.mobilenativefoundation.store6.core.internal.RealStore +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalStoreApi::class) +class StoreEvictionStressTest { + @Test + fun churn10kKeyCycles_neverEvictsHeldEngines_andResidencyStaysBounded() = + runTest(timeout = 120.seconds) { + val allWorkersHolding = CompletableDeferred() + val holdingWorkersLock = Mutex() + var holdingWorkers = 0 + val keys = List(KEY_SPACE) { index -> TestKey("stress-$index") } + val store = + store { + maxIdleKeys(16) + fetcher { "value" } + } as RealStore + + try { + withContext(Dispatchers.Default) { + coroutineScope { + repeat(WORKERS) { worker -> + launch { + val key = keys[worker] + store.withEngine(key) { + holdingWorkersLock.withLock { + holdingWorkers += 1 + if (holdingWorkers == WORKERS) { + allWorkersHolding.complete(Unit) + } + } + allWorkersHolding.await() + repeat(STEPS_PER_WORKER) { operation -> + when (operation % 4) { + 0 -> store.get(key) + 1 -> store.stream(key).awaitFirstDataOrThrow() + 2 -> { + store.invalidate(key) + // Settle refresh before clear; CachedOrFetch would + // deliberately race its background refresh. + store.get(key, Freshness.MustBeFresh) + } + 3 -> store.clear(key) + else -> error("unreachable operation") + } + } + } + } + } + } + } + + assertTrue(WORKERS * STEPS_PER_WORKER >= 10_000) + awaitUntil { + val resident = store.residentEngineCountForTest() + store.createdEngineCountForTest() == WORKERS.toLong() && + store.destroyedEngineCountForTest() == 16L && + resident == 16 && + store.idleEngineCountForTest() == 16 && + store.createdEngineCountForTest() - store.destroyedEngineCountForTest() == + 16L + } + assertEquals(WORKERS.toLong(), store.createdEngineCountForTest()) + assertEquals(16L, store.destroyedEngineCountForTest()) + assertEquals(16, store.residentEngineCountForTest()) + assertEquals(16, store.idleEngineCountForTest()) + assertEquals( + 16L, + store.createdEngineCountForTest() - store.destroyedEngineCountForTest(), + ) + + store.close() + store.awaitTerminationForTest() + assertEquals(0, store.residentEngineCountForTest()) + } finally { + store.close() + } + } + + private suspend fun Flow>.awaitFirstDataOrThrow() { + first { result -> + when (result) { + is StoreResult.Data -> true + is StoreResult.Error -> + throw AssertionError("unexpected Store error: ${result.error}") + is StoreResult.Loading, + is StoreResult.Revalidated, + -> false + } + } + } + + private companion object { + const val WORKERS = 32 + const val STEPS_PER_WORKER = 314 + // Twice the idle cap keeps every worker pinned above the eviction threshold. + const val KEY_SPACE = WORKERS + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreInvalidationConformanceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreInvalidationConformanceTest.kt new file mode 100644 index 000000000..b949721b6 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreInvalidationConformanceTest.kt @@ -0,0 +1,851 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.test +import app.cash.turbine.testIn +import app.cash.turbine.turbineScope +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.flow.produceIn +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest as coroutineRunTest +import kotlinx.coroutines.withContext +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +open class StoreInvalidationConformanceTest : SourceOfTruthSubstitutionTest() { + // An active stream signaled by invalidate observes refetched data. + @Test + fun invalidate_activeStream_observesRefetchedData() = runTest { + var calls = 0 + val secondFetchStarted = CompletableDeferred() + val releaseSecondFetch = CompletableDeferred() + val key = TestKey("1") + val store = testStore { + fetcher { + when (++calls) { + 1 -> "v1" + 2 -> { + secondFetchStarted.complete(Unit) + releaseSecondFetch.await() + "v2" + } + else -> error("unexpected fetch call $calls") + } + } + } + + try { + val seedReader = + store.awaitLocalOnlyMissingReaderBarrier(key, backgroundScope) + assertEquals(0, calls) + awaitCurrentReaderFirstDelivery(key) + + store.stream(key).test { + assertIs(awaitItem()) + val initial = assertIs>(awaitItem()) + assertEquals("v1", initial.value) + assertFalse(initial.isStale) + assertFalse(initial.refreshing) + seedReader.cancel() + + store.invalidate(key) + secondFetchStarted.awaitFromDefault() + releaseSecondFetch.complete(Unit) + + var fresh = assertIs>(awaitItem()) + var queuedStaleReplays = 0 + while (fresh.value == "v1") { + queuedStaleReplays += 1 + assertEquals(1, queuedStaleReplays, "more than one queued stale replay") + assertTrue(fresh.isStale) + assertTrue(fresh.refreshing) + fresh = assertIs>(awaitItem()) + } + assertEquals("v2", fresh.value) + assertFalse(fresh.isStale) + assertFalse(fresh.refreshing) + expectNoEvents() + assertEquals(2, calls) + cancelAndIgnoreRemainingEvents() + } + } finally { + releaseSecondFetch.complete(Unit) + store.closeAndSettleForTest() + } + } + + // Pinned SWR posture: get on a stale resident serves stale now and refetches in background. + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun getOnStaleResident_servesStaleThenRefetchesInBackground() = runTest { + var calls = 0 + val secondFetchStarted = CompletableDeferred() + val releaseSecondFetch = CompletableDeferred() + val key = TestKey("1") + val store = testStore { + fetcher { + when (++calls) { + 1 -> "v1" + 2 -> { + secondFetchStarted.complete(Unit) + releaseSecondFetch.await() + "v2" + } + else -> error("unexpected fetch call $calls") + } + } + } + try { + val seedReader = + store.awaitLocalOnlyMissingReaderBarrier(key, backgroundScope) + assertEquals(0, calls) + awaitCurrentReaderFirstDelivery(key) + + turbineScope { + val initialCollector = store.stream(key).testIn(backgroundScope) + assertIs(initialCollector.awaitItem()) + val initial = assertIs>(initialCollector.awaitItem()) + assertEquals("v1", initial.value) + assertFalse(initial.isStale) + assertFalse(initial.refreshing) + seedReader.cancel() + + store.invalidate(key) + + assertEquals("v1", store.get(key)) // stale served immediately, not blocked + secondFetchStarted.awaitFromDefault() + val collector = store.stream(key).testIn(backgroundScope) + val stale = assertIs>(collector.awaitItem()) + assertEquals("v1", stale.value) + assertTrue(stale.isStale) + assertTrue(stale.refreshing) + + // The stale frame precedes this collector's ticket-watcher enrollment. Drain its + // continuation while fetch 2 is gated so the collector joins before settlement. + runCurrent() + releaseSecondFetch.complete(Unit) + var fresh = assertIs>(collector.awaitItem()) + var queuedStaleReplays = 0 + while (fresh.value == "v1") { + queuedStaleReplays += 1 + assertEquals(1, queuedStaleReplays, "more than one queued stale replay") + assertTrue(fresh.isStale) + assertTrue(fresh.refreshing) + fresh = assertIs>(collector.awaitItem()) + } + assertEquals("v2", fresh.value) + assertFalse(fresh.isStale) + assertFalse(fresh.refreshing) + collector.expectNoEvents() + initialCollector.cancelAndIgnoreRemainingEvents() + collector.cancelAndIgnoreRemainingEvents() + } + assertEquals("v2", store.get(key)) + assertEquals(2, calls) // background refetch and stream fetch single-flighted + } finally { + releaseSecondFetch.complete(Unit) + store.closeAndSettleForTest() + } + } + + // Honesty of age / isStale / refreshing on emissions. + @Test + fun staleResident_newCollector_seesHonestFlagsThenFreshData() = runTest { + var calls = 0 + val secondStarted = CompletableDeferred() + val secondGate = CompletableDeferred() + val store = testStore { + fetcher { + when (++calls) { + 1 -> "v1" + 2 -> { + secondStarted.complete(Unit) + secondGate.await() + "v2" + } + else -> error("unexpected fetch call $calls") + } + } + } + + try { + val key = TestKey("1") + val seedReader = + store.awaitLocalOnlyMissingReaderBarrier(key, backgroundScope) + assertEquals(0, calls) + awaitCurrentReaderFirstDelivery(key) + + turbineScope { + val initialCollector = store.stream(key).testIn(backgroundScope) + assertIs(initialCollector.awaitItem()) + val initial = assertIs>(initialCollector.awaitItem()) + assertEquals("v1", initial.value) + assertFalse(initial.isStale) + assertFalse(initial.refreshing) + seedReader.cancel() + + store.invalidate(key) + secondStarted.awaitFromDefault() + + val collector = store.stream(key).testIn(backgroundScope) + val stale = assertIs>(collector.awaitItem()) + assertEquals("v1", stale.value) + assertTrue(stale.isStale) + assertTrue(stale.refreshing) + assertTrue(stale.age >= Duration.ZERO) + + secondGate.complete(Unit) + + var fresh = assertIs>(collector.awaitItem()) + var queuedStaleReplays = 0 + while (fresh.value == "v1") { + queuedStaleReplays += 1 + assertEquals(1, queuedStaleReplays, "more than one queued stale replay") + assertTrue(fresh.isStale) + assertTrue(fresh.refreshing) + fresh = assertIs>(collector.awaitItem()) + } + assertEquals("v2", fresh.value) + assertFalse(fresh.isStale) + assertFalse(fresh.refreshing) + collector.expectNoEvents() + assertEquals(2, calls) + initialCollector.cancelAndIgnoreRemainingEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } finally { + secondGate.complete(Unit) + store.closeAndSettleForTest() + } + } + + // Clear on an active stream: absent transition (Loading), then refetched data, never stale replay. + @Test + fun clear_activeStream_emitsLoadingThenRefetchedData() = runTest { + var calls = 0 + val secondFetchStarted = CompletableDeferred() + val releaseSecondFetch = CompletableDeferred() + val key = TestKey("1") + val store = testStore { + fetcher { + when (++calls) { + 1 -> "v1" + 2 -> { + secondFetchStarted.complete(Unit) + releaseSecondFetch.await() // hold the refetch so Loading is observable + "v2" + } + else -> error("unexpected fetch call $calls") + } + } + } + + try { + val seedReader = + store.awaitLocalOnlyMissingReaderBarrier(key, backgroundScope) + assertEquals(0, calls) + awaitCurrentReaderFirstDelivery(key) + + store.stream(key).test { + assertIs(awaitItem()) + assertEquals("v1", assertIs>(awaitItem()).value) + seedReader.cancel() + + prepareNextReaderDelivery(key) + store.clear(key) + + assertIs(awaitItem()) // honest absent transition + secondFetchStarted.awaitFromDefault() + awaitCurrentReaderFirstDelivery(key) + assertEquals(2, calls) + releaseSecondFetch.complete(Unit) + assertEquals("v2", assertIs>(awaitItem()).value) + expectNoEvents() + assertEquals(2, calls) + cancelAndIgnoreRemainingEvents() + } + } finally { + releaseSecondFetch.complete(Unit) + store.closeAndSettleForTest() + } + } + + // Clear during an in-flight fetch discards the commit; no resurrection. + @Test + fun clearDuringInFlightFetch_commitDiscarded_noResurrection() = runTest { + var calls = 0 + val firstStarted = CompletableDeferred() + val firstGate = CompletableDeferred() + val store = testStore { + fetcher { + calls++ + if (calls == 1) { + firstStarted.complete(Unit) + firstGate.await() + "doomed-v1" + } else { + "v$calls" + } + } + } + + try { + val waiter = backgroundScope.async { runCatching { store.get(TestKey("1")) } } + firstStarted.awaitFromDefault() + + store.clear(TestKey("1")) + firstGate.complete(Unit) + + val failure = + withContext(Dispatchers.Default) { + waiter.await() + }.exceptionOrNull() + val exception = assertIs(failure) + val missing = assertIs(exception.error) + assertEquals("1", missing.key.canonicalId()) + assertTrue(exception.message!!.contains("test/1")) // which key + assertTrue(exception.message!!.contains("clear")) // what happened + + assertEquals("v2", store.get(TestKey("1"))) // fresh fetch, never "doomed-v1" + } finally { + firstGate.complete(Unit) + store.closeAndSettleForTest() + } + } + + @Test + fun clearDuringInFlightFetch_thatFails_waiterObservesMissing() = runTest { + val fetchStarted = CompletableDeferred() + val fetchGate = CompletableDeferred() + val store = testStore { + fetcher { + fetchStarted.complete(Unit) + fetchGate.await() + error("fetch failed after clear") + } + } + + try { + val waiter = backgroundScope.async { runCatching { store.get(TestKey("1")) } } + fetchStarted.awaitFromDefault() + + store.clear(TestKey("1")) + fetchGate.complete(Unit) + + val failure = + withContext(Dispatchers.Default) { + waiter.await() + }.exceptionOrNull() + val exception = assertIs(failure) + assertIs(exception.error) + assertTrue(exception.message!!.contains("test/1")) + assertTrue(exception.message!!.contains("clear")) + } finally { + fetchGate.complete(Unit) + store.closeAndSettleForTest() + } + } + + @Test + fun invalidateNamespace_touchesOnlyMatchingNamespace() = runTest { + var aCalls = 0 + var bCalls = 0 + val a2Started = CompletableDeferred() + val a2Gate = CompletableDeferred() + val keyA = NamespacedTestKey("a", "1") + val keyB = NamespacedTestKey("b", "1") + val store = testStore { + fetcher { key -> + if (key.namespace.value == "a") { + when (++aCalls) { + 1 -> "a1" + 2 -> { + a2Started.complete(Unit) + a2Gate.await() + "a2" + } + else -> error("unexpected namespace-a fetch call $aCalls") + } + } else { + when (++bCalls) { + 1 -> "b1" + else -> error("unexpected namespace-b fetch call $bCalls") + } + } + } + } + + try { + val seedReaderA = + store.awaitLocalOnlyMissingReaderBarrier(keyA, backgroundScope) + val seedReaderB = + store.awaitLocalOnlyMissingReaderBarrier(keyB, backgroundScope) + assertEquals(0, aCalls) + assertEquals(0, bCalls) + awaitCurrentReaderFirstDelivery(keyA) + awaitCurrentReaderFirstDelivery(keyB) + + turbineScope { + val initialCollector = store.stream(keyA).testIn(backgroundScope) + assertIs(initialCollector.awaitItem()) + val initial = assertIs>(initialCollector.awaitItem()) + assertEquals("a1", initial.value) + assertFalse(initial.isStale) + assertFalse(initial.refreshing) + assertEquals("b1", store.get(keyB)) + seedReaderA.cancel() + seedReaderB.cancel() + + store.invalidateNamespace(StoreNamespace("a")) + a2Started.awaitFromDefault() + + assertEquals("b1", store.get(keyB)) // untouched, no refetch + assertEquals(1, bCalls) + assertEquals("a1", store.get(keyA)) // stale served immediately + + val collector = store.stream(keyA).testIn(backgroundScope) + val stale = assertIs>(collector.awaitItem()) + assertEquals("a1", stale.value) + assertTrue(stale.isStale) + assertTrue(stale.refreshing) + a2Gate.complete(Unit) + var fresh = assertIs>(collector.awaitItem()) + var queuedStaleReplays = 0 + while (fresh.value == "a1") { + queuedStaleReplays += 1 + assertEquals(1, queuedStaleReplays, "more than one queued stale replay") + assertTrue(fresh.isStale) + assertTrue(fresh.refreshing) + fresh = assertIs>(collector.awaitItem()) + } + assertEquals("a2", fresh.value) + assertFalse(fresh.isStale) + assertFalse(fresh.refreshing) + collector.expectNoEvents() + initialCollector.cancelAndIgnoreRemainingEvents() + collector.cancelAndIgnoreRemainingEvents() + } + assertEquals(2, aCalls) + } finally { + a2Gate.complete(Unit) + store.closeAndSettleForTest() + } + } + + @Test + fun invalidateNamespace_wakesOnlyMatchingResidentCollector() = runTest { + var aCalls = 0 + var bCalls = 0 + val aRefreshStarted = CompletableDeferred() + val releaseARefresh = CompletableDeferred() + val keyA = NamespacedTestKey("a", "1") + val keyB = NamespacedTestKey("b", "1") + val store = testStore { + fetcher { key -> + if (key.namespace.value == "a") { + when (++aCalls) { + 1 -> "a1" + 2 -> { + aRefreshStarted.complete(Unit) + releaseARefresh.await() + "a2" + } + else -> error("unexpected namespace-a fetch call $aCalls") + } + } else { + when (++bCalls) { + 1 -> "b1" + else -> error("unexpected namespace-b fetch call $bCalls") + } + } + } + } + + try { + val seedReaderA = + store.awaitLocalOnlyMissingReaderBarrier(keyA, backgroundScope) + val seedReaderB = + store.awaitLocalOnlyMissingReaderBarrier(keyB, backgroundScope) + assertEquals(0, aCalls) + assertEquals(0, bCalls) + awaitCurrentReaderFirstDelivery(keyA) + awaitCurrentReaderFirstDelivery(keyB) + + turbineScope { + val aCollector = store.stream(keyA).testIn(backgroundScope) + val bCollector = store.stream(keyB).testIn(backgroundScope) + assertIs(aCollector.awaitItem()) + assertEquals("a1", assertIs>(aCollector.awaitItem()).value) + assertIs(bCollector.awaitItem()) + assertEquals("b1", assertIs>(bCollector.awaitItem()).value) + seedReaderA.cancel() + seedReaderB.cancel() + + store.invalidateNamespace(StoreNamespace("a")) + aRefreshStarted.awaitFromDefault() + bCollector.expectNoEvents() + assertEquals(1, bCalls) + + releaseARefresh.complete(Unit) + while (true) { + val item = aCollector.awaitItem() + if (item is StoreResult.Data && item.value == "a2") break + } + bCollector.expectNoEvents() + assertEquals(2, aCalls) + assertEquals(1, bCalls) + aCollector.cancelAndIgnoreRemainingEvents() + bCollector.cancelAndIgnoreRemainingEvents() + } + } finally { + releaseARefresh.complete(Unit) + store.closeAndSettleForTest() + } + } + + @Test + fun invalidateAll_wakesResidentCollector() = runTest { + var calls = 0 + val refreshStarted = CompletableDeferred() + val releaseRefresh = CompletableDeferred() + val store = testStore { + fetcher { + when (++calls) { + 1 -> "v1" + 2 -> { + refreshStarted.complete(Unit) + releaseRefresh.await() + "v2" + } + else -> error("unexpected fetch call $calls") + } + } + } + + try { + val key = TestKey("1") + val seedReader = + store.awaitLocalOnlyMissingReaderBarrier(key, backgroundScope) + assertEquals(0, calls) + awaitCurrentReaderFirstDelivery(key) + + store.stream(key).test { + assertIs(awaitItem()) + assertEquals("v1", assertIs>(awaitItem()).value) + seedReader.cancel() + + store.invalidateAll() + refreshStarted.awaitFromDefault() + releaseRefresh.complete(Unit) + while (true) { + val item = awaitItem() + if (item is StoreResult.Data && item.value == "v2") break + } + assertEquals(2, calls) + cancelAndIgnoreRemainingEvents() + } + } finally { + releaseRefresh.complete(Unit) + store.closeAndSettleForTest() + } + } + + @Test + fun clearNamespace_deletesAffectedRowsAndKeepsOtherNamespace() = runTest { + var calls = 0 + val keyA = NamespacedTestKey("a", "1") + val keyB = NamespacedTestKey("b", "1") + val store = testStore { fetcher { "v${++calls}" } } + + try { + assertEquals("v1", store.get(keyA)) + assertEquals("v2", store.get(keyB)) + store.clearNamespace(StoreNamespace("a")) + + assertEquals("v2", store.get(keyB, Freshness.LocalOnly)) + val missing = assertFailsWith { + store.get(keyA, Freshness.LocalOnly) + } + assertIs(missing.error) + assertEquals("v3", store.get(keyA)) + assertEquals(3, calls) + } finally { + store.closeAndSettleForTest() + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun clear_thenNewStreamEmitsLoadingNeverStaleReplay() = runTest { + var calls = 0 + val refetchStarted = CompletableDeferred() + val releaseRefetch = CompletableDeferred() + val key = TestKey("1") + val store = testStore { + fetcher { + when (++calls) { + 1 -> "v1" + 2 -> { + refetchStarted.complete(Unit) + releaseRefetch.await() + "v2" + } + else -> error("unexpected fetch call $calls") + } + } + } + + try { + assertEquals("v1", store.get(key)) + prepareNextReaderDelivery(key) + store.clear(key) + store.stream(key).test { + assertIs(awaitItem()) + refetchStarted.awaitFromDefault() + // The initial Loading precedes ticket-watcher enrollment. Drain that continuation + // while fetch 2 remains gated so post-clear demand joins before the outcome settles. + runCurrent() + awaitCurrentReaderFirstDelivery(key) + assertEquals(2, calls) + releaseRefetch.complete(Unit) + awaitDataValue(expected = "v2") + cancelAndIgnoreRemainingEvents() + } + assertEquals(2, calls) + } finally { + releaseRefetch.complete(Unit) + store.closeAndSettleForTest() + } + } + + @Test + fun clearNamespace_activeLocalOnlyStreamObservesMissingWithoutRefetch() = runTest { + var calls = 0 + val key = NamespacedTestKey("a", "1") + val store = + testStore { + fetcher { "v${++calls}" } + } + + try { + assertEquals("v1", store.get(key)) + turbineScope { + val collector = + store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + val resident = assertIs>(collector.awaitItem()) + assertEquals("v1", resident.value) + assertFalse(resident.isStale) + assertFalse(resident.refreshing) + awaitCurrentReaderFirstDelivery(key) + + store.clearNamespace(StoreNamespace("a")) + + // Fenced clear: an already-active pipeline may queue one duplicate pre-clear + // Data frame. Drain it exactly; Loading then Missing must still follow. + var frame = collector.awaitItem() + var queuedPreClearReplays = 0 + while (frame !is StoreResult.Loading) { + queuedPreClearReplays += 1 + assertEquals(1, queuedPreClearReplays, "more than one queued pre-clear replay") + val replay = assertIs>(frame) + assertEquals("v1", replay.value) + assertFalse(replay.isStale) + assertFalse(replay.refreshing) + frame = collector.awaitItem() + } + val missing = assertIs(collector.awaitItem()) + assertIs(missing.error) + assertFalse(missing.servedStale) + assertEquals(1, calls) + collector.cancelAndIgnoreRemainingEvents() + } + } finally { + store.closeAndSettleForTest() + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun clearNamespace_thenNewStreamEmitsLoadingNeverPreClearData() = runTest { + var calls = 0 + val refetchStarted = CompletableDeferred() + val releaseRefetch = CompletableDeferred() + val key = NamespacedTestKey("a", "1") + val store = testStore { + fetcher { + when (++calls) { + 1 -> "v1" + 2 -> { + refetchStarted.complete(Unit) + releaseRefetch.await() + "v2" + } + else -> error("unexpected fetch call $calls") + } + } + } + + try { + assertEquals("v1", store.get(key)) + turbineScope { + // No shared reader is active during the two bulk-clear sweeps. A retained + // post-clear LocalOnly observer then starts directly from the final generation, + // making its downstream null-delivery acknowledgement unambiguous. + store.clearNamespace(StoreNamespace("a")) + assertEquals(1, calls) + val postClearReader = + store.awaitLocalOnlyMissingReaderBarrier(key, backgroundScope) + assertEquals(1, calls) + awaitCurrentReaderFirstDelivery(key) + + val collector = store.stream(key).testIn(backgroundScope) + assertIs(collector.awaitItem()) + refetchStarted.awaitFromDefault() + // The new collector's initial Loading is sent before StreamDelivery.start installs + // its ticket watcher. Drain that continuation while fetch 2 remains gated so its + // post-clear demand is enrolled before the shared outcome can settle. + runCurrent() + assertEquals(2, calls) + releaseRefetch.complete(Unit) + val fresh = collector.awaitFreshDataAfterClear(forbidden = "v1") + assertEquals("v2", fresh.value) + assertFalse(fresh.isStale) + assertFalse(fresh.refreshing) + collector.expectNoEvents() + val localFresh = assertIs>(postClearReader.receive()) + assertEquals("v2", localFresh.value) + assertFalse(localFresh.isStale) + assertFalse(localFresh.refreshing) + postClearReader.cancel() + collector.cancelAndIgnoreRemainingEvents() + } + assertEquals(2, calls) + } finally { + releaseRefetch.complete(Unit) + store.closeAndSettleForTest() + } + } + + @Test + fun clearAll_dropsResidenceForEveryKey() = runTest { + var calls = 0 + val store = testStore { fetcher { "v${++calls}" } } + + try { + assertEquals("v1", store.get(NamespacedTestKey("a", "1"))) + assertEquals("v2", store.get(NamespacedTestKey("b", "2"))) + + store.clearAll() + + // Residence is gone: both keys refetch. + assertEquals("v3", store.get(NamespacedTestKey("a", "1"))) + assertEquals("v4", store.get(NamespacedTestKey("b", "2"))) + } finally { + store.closeAndSettleForTest() + } + } + + @Test + fun maintenanceAfterClose_failsFastWithDeterministicException() = runTest { + val store = testStore { fetcher { "v" } } + try { + store.close() + + assertEquals( + "Store is closed.", + assertFailsWith { store.invalidate(TestKey("1")) }.message, + ) + assertEquals( + "Store is closed.", + assertFailsWith { store.clearAll() }.message, + ) + } finally { + store.closeAndSettleForTest() + } + } + + private suspend fun app.cash.turbine.ReceiveTurbine>.awaitDataValue( + expected: String, + ) { + while (true) { + when (val item = awaitItem()) { + is StoreResult.Data -> { + assertEquals(expected, item.value, "pre-clear Data must never replay") + return + } + is StoreResult.Loading -> Unit + is StoreResult.Error -> { + val cause = (item.error as? StoreError.Fetch)?.cause + throw AssertionError( + "unexpected clear-cycle error: ${item.error}; cause=${cause?.message}", + cause, + ) + } + is StoreResult.Revalidated -> throw AssertionError("clear must not revalidate") + } + } + } + + private suspend fun app.cash.turbine.ReceiveTurbine>.awaitFreshDataAfterClear( + forbidden: String, + ): StoreResult.Data { + while (true) { + when (val item = awaitItem()) { + is StoreResult.Data -> { + assertTrue(item.value != forbidden, "pre-clear Data must never replay") + if (!item.isStale && !item.refreshing) return item + } + is StoreResult.Loading -> Unit + is StoreResult.Error -> { + val cause = (item.error as? StoreError.Fetch)?.cause + throw AssertionError( + "unexpected clear-cycle error: ${item.error}; cause=${cause?.message}", + cause, + ) + } + is StoreResult.Revalidated -> throw AssertionError("clear must not revalidate") + } + } + } +} + +// Turbine's 3s default would nest inside the 25s shadow. Raising the Turbine deadline above +// the shadow makes runTest the only effective timeout. +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds // strictly > TEST_TIMEOUT: the shadow must fire first + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } + +private suspend fun Store.awaitLocalOnlyMissingReaderBarrier( + key: K, + scope: kotlinx.coroutines.CoroutineScope, +): ReceiveChannel> { + val collector = stream(key, Freshness.LocalOnly).produceIn(scope) + val missing = assertIs(collector.receive()) + assertIs(missing.error) + assertFalse(missing.servedStale) + return collector +} + +// Preserve the cross-scheduler hop; runTest owns the timeout so broad-graph load cannot +// expire a shorter wall-clock deadline before the causal event reaches Dispatchers.Default. +private suspend fun CompletableDeferred.awaitFromDefault(): T = + withContext(Dispatchers.Default) { + await() + } diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreInvalidationStressTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreInvalidationStressTest.kt new file mode 100644 index 000000000..7f09d294e --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreInvalidationStressTest.kt @@ -0,0 +1,107 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.test +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlin.test.Test +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.test.fail +import kotlin.time.Duration.Companion.seconds + +abstract class StoreInvalidationStressConformance : SourceOfTruthSubstitutionTest() { + @Test + fun invalidate_burstOf10k_convergesWithoutLosingFinalStaleness() = + runTest(timeout = 240.seconds) { + val callsLock = Mutex() + var calls = 0 + var gateNextFetch = false + val finalFetchEntered = CompletableDeferred() + val finalFetchCall = CompletableDeferred() + val releaseFinalFetch = CompletableDeferred() + val store = testStore { + fetcher { + val (call, shouldGate) = callsLock.withLock { + calls += 1 + val armed = gateNextFetch + gateNextFetch = false + calls to armed + } + if (shouldGate) { + finalFetchCall.complete(call) + finalFetchEntered.complete(Unit) + releaseFinalFetch.await() + } + "v$call" + } + } + val initialDataSeen = CompletableDeferred() + val burstCollector = backgroundScope.launch { + store.stream(TestKey("1")).collect { result -> + if (result is StoreResult.Data) initialDataSeen.complete(Unit) + } + } + + try { + initialDataSeen.awaitFromDefaultContext() + coroutineScope { + repeat(10) { + launch { + repeat(1_000) { + store.invalidate(TestKey("1")) + store.get(TestKey("1")) + } + } + } + } + assertTrue(callsLock.withLock { calls >= 2 }) + + burstCollector.cancelAndJoin() + store.get(TestKey("1"), Freshness.MustBeFresh) + callsLock.withLock { gateNextFetch = true } + store.invalidate(TestKey("1")) + + store.stream(TestKey("1")).test(timeout = 60.seconds) { + val finalStale = assertIs>(awaitItem()) + assertTrue(finalStale.isStale) + assertTrue(finalStale.refreshing) + finalFetchEntered.awaitFromDefaultContext() + val expectedFreshValue = "v${finalFetchCall.awaitFromDefaultContext()}" + releaseFinalFetch.complete(Unit) + while (true) { + when (val item = awaitItem()) { + is StoreResult.Data -> { + if (!item.isStale && !item.refreshing) { + kotlin.test.assertEquals(expectedFreshValue, item.value) + break + } + } + is StoreResult.Loading -> Unit + is StoreResult.Error -> fail("unexpected stress error: ${item.error}") + is StoreResult.Revalidated -> fail("Success fetches must not revalidate") + } + } + cancelAndIgnoreRemainingEvents() + } + } finally { + releaseFinalFetch.complete(Unit) + burstCollector.cancel() + store.close() + burstCollector.join() + } + } +} + +class StoreInvalidationStressTest : StoreInvalidationStressConformance() + +private suspend fun CompletableDeferred.awaitFromDefaultContext(): T = + withContext(Dispatchers.Default) { + await() + } diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreResultsTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreResultsTest.kt new file mode 100644 index 000000000..35a248054 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreResultsTest.kt @@ -0,0 +1,115 @@ +package org.mobilenativefoundation.store6.core + +import org.mobilenativefoundation.store6.core.seam.StoreResults +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalStoreApi::class) +class StoreResultsTest { + + @Test + fun resultFactories_roundTripEveryPayload() { + assertIs(StoreResults.loading()) + + val data = StoreResults.data( + value = "v", + origin = Origin.MEMORY, + age = 2.seconds, + isStale = false, + refreshing = true, + ) + assertEquals("v", data.value) + assertEquals(Origin.MEMORY, data.origin) + assertEquals(2.seconds, data.age) + assertFalse(data.isStale) + assertTrue(data.refreshing) + + val nullableData: StoreResult.Data = StoreResults.data( + value = null, + origin = Origin.OVERLAY, + age = 3.seconds, + isStale = true, + refreshing = false, + ) + assertNull(nullableData.value) + assertEquals(Origin.OVERLAY, nullableData.origin) + assertEquals(3.seconds, nullableData.age) + assertTrue(nullableData.isStale) + assertFalse(nullableData.refreshing) + + assertEquals(4.seconds, StoreResults.revalidated(age = 4.seconds).age) + + val error: StoreError.Fetch = StoreResults.fetchError(message = "fetch") + val wrapped: StoreResult.Error = StoreResults.error(error = error, servedStale = true) + assertSame(error, wrapped.error) + assertTrue(wrapped.servedStale) + } + + @Test + fun errorFactories_roundTripEveryPayload() { + val fetchCause = IllegalStateException("fetch cause") + val fetch = StoreResults.fetchError(message = "fetch", cause = fetchCause) + assertEquals("fetch", fetch.message) + assertSame(fetchCause, fetch.cause) + assertNull(StoreResults.fetchError(message = "fetch default").cause) + assertNull(StoreResults.fetchError(message = "fetch null", cause = null).cause) + + val persistenceCause = IllegalArgumentException("persistence cause") + val persistence = StoreResults.persistenceError( + message = "persistence", + cause = persistenceCause, + ) + assertEquals("persistence", persistence.message) + assertSame(persistenceCause, persistence.cause) + assertNull(StoreResults.persistenceError(message = "persistence default").cause) + assertNull(StoreResults.persistenceError(message = "persistence null", cause = null).cause) + + val conversionCause = UnsupportedOperationException("conversion cause") + val conversion = StoreResults.conversionError( + message = "conversion", + cause = conversionCause, + ) + assertEquals("conversion", conversion.message) + assertSame(conversionCause, conversion.cause) + assertNull(StoreResults.conversionError(message = "conversion default").cause) + assertNull(StoreResults.conversionError(message = "conversion null", cause = null).cause) + + val freshness = StoreResults.freshnessUnsatisfiable(message = "freshness") + assertEquals("freshness", freshness.message) + + val serverMeta = object : StoreMeta { + override val writtenAtEpochMillis: Long = 42L + override val etag: String = "etag" + } + val conflict = StoreResults.conflict(serverMeta = serverMeta, message = "conflict") + assertSame(serverMeta, conflict.serverMeta) + assertEquals("conflict", conflict.message) + assertNull(StoreResults.conflict(serverMeta = null, message = "no metadata").serverMeta) + + val key = TestKey("missing") + val missing = StoreResults.missing(key = key, message = "missing") + assertSame(key, missing.key) + assertEquals("missing", missing.message) + } + + @Test + fun exceptionFactory_roundTripsErrorCauseAndMessage() { + val error: StoreError.Persistence = StoreResults.persistenceError(message = "persistence") + val cause = IllegalStateException("exception cause") + val exception: StoreException = StoreResults.exception(error = error, cause = cause) + + assertSame(error, exception.error) + assertSame(cause, exception.cause) + assertEquals("persistence", exception.message) + assertNull(StoreResults.exception(error).cause) + + assertEquals("m", StoreResults.exception(StoreResults.fetchError("m")).message) + assertIs(StoreResults.exception(StoreResults.fetchError("m")).error) + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreRevalidationConformanceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreRevalidationConformanceTest.kt new file mode 100644 index 000000000..a36166d6a --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreRevalidationConformanceTest.kt @@ -0,0 +1,238 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.test +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import kotlinx.coroutines.withContext +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.test.fail +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +abstract class StoreRevalidationConformance : SourceOfTruthSubstitutionTest() { + @Test + fun conditionalRefetch_notModified_emitsOwnerRevalidatedAndClearsStaleness() = runTest { + var calls = 0 + val notModifiedGate = CompletableDeferred() + val store = testStore { + fetcherOfResult { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> { + notModifiedGate.await() + FetcherResult.NotModified("e1") + } + // A cold-baseline 304 commits ObsoleteRevalidation and legally self-heals + // with exactly one replanned conditional fetch. + 3 -> FetcherResult.NotModified("e1") + else -> error("unexpected fetch call $calls") + } + } + } + + try { + store.stream(TestKey("1")).test { + assertIs(awaitItem()) + assertEquals("v1", assertIs>(awaitItem()).value) + store.invalidate(TestKey("1")) + notModifiedGate.complete(Unit) + + while (true) { + when (val item = awaitItem()) { + is StoreResult.Data -> { + if (item.origin == Origin.FETCHER && !item.isStale) { + fail("legacy fresh FETCHER Data must be replaced by Revalidated") + } + } + is StoreResult.Revalidated -> { + assertTrue(item.age >= Duration.ZERO) + break + } + else -> fail("unexpected lifecycle item ${item::class.simpleName}") + } + } + cancelAndIgnoreRemainingEvents() + } + + val callsAfterRevalidated = calls + assertTrue( + callsAfterRevalidated in 2..3, + "the 304 cycle may self-heal one obsolete cold-baseline launch", + ) + assertEquals("v1", store.get(TestKey("1"))) + assertEquals( + callsAfterRevalidated, + calls, + "successful 304 must clear staleness before later planning", + ) + } finally { + notModifiedGate.complete(Unit) + store.close() + } + } + + @Test + fun slowCollector_neverLosesRevalidatedWhileConsecutiveDataConflates() = runTest { + var calls = 0 + val secondFetchEntered = CompletableDeferred() + val releaseNotModified = CompletableDeferred() + val firstDataSeen = CompletableDeferred() + val releaseSlowCollector = CompletableDeferred() + val slowSawRevalidated = CompletableDeferred() + val received = mutableListOf>() + val key = TestKey("1") + val store = testStore { + fetcherOfResult { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> { + secondFetchEntered.complete(Unit) + releaseNotModified.await() + FetcherResult.NotModified("e1") + } + // A cold-baseline 304 commits ObsoleteRevalidation and legally self-heals + // with exactly one replanned conditional fetch. + 3 -> FetcherResult.NotModified("e1") + else -> error("unexpected fetch call $calls") + } + } + } + val slowCollector = backgroundScope.launch { + store.stream(key).collect { result -> + received += result + when { + result is StoreResult.Data && !firstDataSeen.isCompleted -> { + firstDataSeen.complete(Unit) + releaseSlowCollector.await() + } + result is StoreResult.Revalidated -> slowSawRevalidated.complete(Unit) + } + } + } + + try { + firstDataSeen.awaitFromDefaultContext() + store.invalidate(key) + secondFetchEntered.awaitFromDefaultContext() + + store.stream(key).test { + val joiningData = assertIs>(awaitItem()) + assertEquals("v1", joiningData.value) + assertTrue(joiningData.isStale) + assertTrue(joiningData.refreshing) + releaseNotModified.complete(Unit) + + while (true) { + when (val item = awaitItem()) { + is StoreResult.Data -> { + if (item.origin == Origin.FETCHER && !item.isStale) { + fail("legacy fresh FETCHER Data must be replaced by Revalidated") + } + } + is StoreResult.Revalidated -> break + else -> fail("unexpected lifecycle item ${item::class.simpleName}") + } + } + cancelAndIgnoreRemainingEvents() + } + + releaseSlowCollector.complete(Unit) + slowSawRevalidated.awaitFromDefaultContext() + val afterInitialData = received.dropWhile { it !is StoreResult.Data }.drop(1) + val beforeRevalidated = afterInitialData.takeWhile { it !is StoreResult.Revalidated } + assertTrue(afterInitialData.any { it is StoreResult.Revalidated }) + assertTrue( + beforeRevalidated.count { it is StoreResult.Data<*> } <= 1, + "a blocked collector may retain only the latest consecutive Data before Revalidated", + ) + assertTrue(calls in 2..3, "the 304 cycle may self-heal one obsolete cold-baseline launch") + } finally { + releaseNotModified.complete(Unit) + releaseSlowCollector.complete(Unit) + slowCollector.cancel() + store.close() + slowCollector.join() + } + } + + @Test + fun slowCollector_neverLosesPostClearLoadingBeforeRefetchCompletes() = runTest { + var calls = 0 + val firstDataSeen = CompletableDeferred() + val releaseSlowCollector = CompletableDeferred() + val refetchEntered = CompletableDeferred() + val releaseRefetch = CompletableDeferred() + val slowSawLoading = CompletableDeferred() + val slowSawFreshData = CompletableDeferred() + val store = testStore { + fetcher { + val call = ++calls + if (call == 2) { + refetchEntered.complete(Unit) + releaseRefetch.await() + } + "v$call" + } + } + val slowCollector = backgroundScope.launch { + store.stream(TestKey("1")).collect { result -> + when { + result is StoreResult.Data && !firstDataSeen.isCompleted -> { + firstDataSeen.complete(Unit) + releaseSlowCollector.await() + } + result is StoreResult.Loading && firstDataSeen.isCompleted -> + slowSawLoading.complete(Unit) + result is StoreResult.Data && result.value == "v2" -> + slowSawFreshData.complete(Unit) + } + } + } + + try { + firstDataSeen.awaitFromDefaultContext() + store.clear(TestKey("1")) + refetchEntered.awaitFromDefaultContext() + releaseSlowCollector.complete(Unit) + slowSawLoading.awaitFromDefaultContext() + assertTrue(!releaseRefetch.isCompleted, "Loading must survive before refetch is released") + releaseRefetch.complete(Unit) + slowSawFreshData.awaitFromDefaultContext() + assertEquals(2, calls) + } finally { + releaseSlowCollector.complete(Unit) + releaseRefetch.complete(Unit) + slowCollector.cancel() + store.close() + slowCollector.join() + } + } +} + +class StoreRevalidationConformanceTest : StoreRevalidationConformance() + +// Turbine's 3s default would nest inside the 25s shadow. Raising the Turbine deadline above +// the shadow makes runTest the only effective timeout. +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds // strictly > TEST_TIMEOUT: the shadow must fire first + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } + +// Preserve Default-dispatch ordering and let the suite-level runTest bound own cancellation. +private suspend fun CompletableDeferred.awaitFromDefaultContext(): T = + withContext(Dispatchers.Default) { + await() + } diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreRuntimeTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreRuntimeTest.kt new file mode 100644 index 000000000..c33d3dabb --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreRuntimeTest.kt @@ -0,0 +1,164 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.test +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.internal.InMemorySourceOfTruth +import org.mobilenativefoundation.store6.core.seam.KeyEvents +import org.mobilenativefoundation.store6.core.seam.runtime +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) +class StoreRuntimeTest { + private suspend fun app.cash.turbine.ReceiveTurbine>.awaitDataValue( + expected: String, + ): StoreResult.Data { + while (true) { + val item = awaitItem() + if (item is StoreResult.Data && item.value == expected) return item + } + } + + @Test + fun keyEvents_writtenInvalidatedDeleted_inOrder() = runTest { + val store = store { fetcher { "v" } } + val runtime = assertNotNull(store.runtime()) + runtime.keyEvents.test { + store.get(TestKey("1")) + // Written is tryEmitted BEFORE the ticket completes (placement pin), so it is on the + // bus before this thread resumed from get() -- strictly before the Invalidated below. + val written = assertIs(awaitItem()) + assertEquals("1", written.key.canonicalId()) + assertEquals(Origin.FETCHER, written.origin) + store.invalidate(TestKey("1")) + assertIs(awaitItem()) + store.clear(TestKey("1")) + assertIs(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + @Test + fun writeHandle_apply_emitsSotData_withoutFetch() = runTest { + var fetches = 0 + val store = store { fetcher { fetches++; "fetched" } } + val handle = assertNotNull(store.runtime()).writeHandle + store.stream(TestKey("1")).test { + awaitDataValue("fetched") + handle.apply(TestKey("1"), "applied") + val applied = awaitDataValue("applied") + assertEquals(Origin.SOT, applied.origin) + assertEquals(1, fetches) + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + @Test + fun writeHandle_apply_settledEnvelopeKeepsSotOrigin() = runTest { + // Guards the T0-verified recognition amendment: the envelope-freshness recognition sites + // compare envelope.origin == Origin.FETCHER; unless generalized to installed-envelope + // identity, the SoT echo row would not reuse the installed SOT envelope and a fresh + // collection's first Data would expose a FETCHER (or conservative) envelope. + val store = store { fetcher { "fetched" } } + val handle = store.runtime()!!.writeHandle + store.get(TestKey("1")) + handle.apply(TestKey("1"), "applied") + store.stream(TestKey("1")).test { + // Fresh collection after apply and its source-of-truth echo settle. + val first = awaitDataValue("applied") + assertEquals(Origin.SOT, first.origin) + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + @Test + fun writeHandle_apply_echoRowReusesInstalledEnvelope_andExternalWriteStillWins() = runTest { + // The echo is the one matching pre-cutoff row and reuses the installed SOT envelope; a + // later external source-of-truth write is post-cutoff and wins as later authority. + val sot = InMemorySourceOfTruth() + val secondFetchStarted = CompletableDeferred() + val releaseSecondFetch = CompletableDeferred() + var fetches = 0 + val store = store { + fetcher { + when (val call = ++fetches) { + 1 -> "fetched" + 2 -> { + secondFetchStarted.complete(Unit) + releaseSecondFetch.await() + "replacement" + } + + else -> error("unexpected fetch call $call") + } + } + persistence(sot) + } + val handle = assertNotNull(store.runtime()).writeHandle + try { + store.stream(TestKey("1")).test { + awaitDataValue("fetched") + handle.apply(TestKey("1"), "applied") + assertEquals(Origin.SOT, awaitDataValue("applied").origin) + sot.write(TestKey("1"), "external") + withContext(Dispatchers.Default) { + secondFetchStarted.await() + } + assertEquals(Origin.SOT, awaitDataValue("external").origin) + releaseSecondFetch.complete(Unit) + cancelAndIgnoreRemainingEvents() + } + } finally { + releaseSecondFetch.complete(Unit) + store.close() + } + } + + @Test + fun writeHandle_markStale_signalsRefetch() = runTest { + var fetches = 0 + val store = store { fetcher { fetches++; "v$fetches" } } + store.stream(TestKey("1")).test { + awaitDataValue("v1") + store.runtime()!!.writeHandle.markStale(TestKey("1")) + awaitDataValue("v2") + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + @Test + fun writeHandle_confirmFresh_clearsStalenessWithoutFetch() = runTest { + var fetches = 0 + val store = store { fetcher { fetches++; "v$fetches" } } + assertEquals("v1", store.get(TestKey("1"))) + store.invalidate(TestKey("1")) + store.runtime()!!.writeHandle.confirmFresh(TestKey("1"), etag = null) + assertEquals("v1", store.get(TestKey("1"))) + assertEquals(1, fetches) + store.close() + } +} + +// Turbine's 3s default would nest inside the 25s shadow. Raising the Turbine deadline above +// the shadow makes runTest the only effective timeout. +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds // strictly > TEST_TIMEOUT: the shadow must fire first + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreScopedMaintenanceRaceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreScopedMaintenanceRaceTest.kt new file mode 100644 index 000000000..12e20642e --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreScopedMaintenanceRaceTest.kt @@ -0,0 +1,240 @@ +package org.mobilenativefoundation.store6.core + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import kotlinx.coroutines.withContext +import org.mobilenativefoundation.store6.core.internal.InMemorySourceOfTruth +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalStoreApi::class) +class StoreScopedMaintenanceRaceTest { + @Test + fun invalidateNamespace_watermarkThenLaterSuccessSuppressesResidentSignal() = runTest { + val successEntered = CompletableDeferred() + val releaseSuccess = CompletableDeferred() + val durableBookkeeper = RecordingBookkeeper() + var calls = 0 + val store = store { + fetcherOfResult { FetcherResult.Success("v${++calls}", etag = "e$calls") } + bookkeeper(durableBookkeeper) + } + val key = NamespacedTestKey("a", "1") + assertEquals("v1", store.get(key)) + durableBookkeeper.successEntered = successEntered + durableBookkeeper.releaseSuccess = releaseSuccess + + val laterSuccess = backgroundScope.async { store.get(key, Freshness.MustBeFresh) } + testScheduler.runCurrent() + try { + awaitFromDefault { successEntered.await() } + val invalidation = backgroundScope.async { + store.invalidateNamespace(StoreNamespace("a")) + } + testScheduler.runCurrent() + assertTrue( + durableBookkeeper.log.contains("advanceStaleWatermark:a"), + "watermark must advance before resident status is rechecked", + ) + releaseSuccess.complete(Unit) + assertEquals("v2", awaitFromDefault { laterSuccess.await() }) + awaitFromDefault { invalidation.await() } + val watermarkIndex = durableBookkeeper.log.indexOf("advanceStaleWatermark:a") + val statusAfterWatermark = + durableBookkeeper.log.indexOfLast { it == "status:a/1" } + assertTrue( + statusAfterWatermark > watermarkIndex, + "resident signaling must reload status after the namespace watermark", + ) + assertEquals("v2", store.get(key)) + assertEquals(2, calls, "later success must satisfy the earlier watermark") + } finally { + releaseSuccess.complete(Unit) + } + store.close() + } + + @Test + fun invalidateAll_globalWatermarkThenLaterSuccessSuppressesResidentSignal() = runTest { + val successEntered = CompletableDeferred() + val releaseSuccess = CompletableDeferred() + val durableBookkeeper = RecordingBookkeeper() + var calls = 0 + val store = store { + fetcherOfResult { FetcherResult.Success("v${++calls}", etag = "e$calls") } + bookkeeper(durableBookkeeper) + } + val key = TestKey("1") + assertEquals("v1", store.get(key)) + durableBookkeeper.successEntered = successEntered + durableBookkeeper.releaseSuccess = releaseSuccess + + val laterSuccess = backgroundScope.async { store.get(key, Freshness.MustBeFresh) } + testScheduler.runCurrent() + try { + awaitFromDefault { successEntered.await() } + val invalidation = backgroundScope.async { store.invalidateAll() } + testScheduler.runCurrent() + assertTrue( + durableBookkeeper.log.contains("advanceGlobalStaleWatermark"), + "global watermark must advance before resident status is rechecked", + ) + releaseSuccess.complete(Unit) + assertEquals("v2", awaitFromDefault { laterSuccess.await() }) + awaitFromDefault { invalidation.await() } + val watermarkIndex = durableBookkeeper.log.indexOf("advanceGlobalStaleWatermark") + val statusAfterWatermark = durableBookkeeper.log.indexOfLast { it == "status:test/1" } + assertTrue( + statusAfterWatermark > watermarkIndex, + "resident signaling must reload status after the global watermark", + ) + assertEquals("v2", store.get(key)) + assertEquals(2, calls) + } finally { + releaseSuccess.complete(Unit) + } + store.close() + } + + @Test + fun clearNamespace_fencesAffectedCommitTailsButAllowsUnrelatedCommit() = runTest { + val backing = InMemorySourceOfTruth() + val gated = PostDeleteGateSourceOfTruth(backing) + val affectedFetchStarted = CompletableDeferred() + val betweenSweepsFetchStarted = CompletableDeferred() + val releaseAffectedFetch = CompletableDeferred() + val counts = mutableMapOf() + val store = store { + fetcher { key -> + val identity = "${key.namespace.value}/${key.canonicalId()}" + val call = counts.getOrElse(identity) { 0 } + 1 + counts[identity] = call + if (identity == "a/1" && call == 2) { + affectedFetchStarted.complete(Unit) + releaseAffectedFetch.await() + } + if (identity == "a/2") betweenSweepsFetchStarted.complete(Unit) + "$identity-v$call" + } + persistence(gated) + } + val affected = NamespacedTestKey("a", "1") + val betweenSweeps = NamespacedTestKey("a", "2") + val unrelated = NamespacedTestKey("b", "1") + store.get(affected) + store.get(unrelated) + val oldTail = backgroundScope.async { runCatching { store.get(affected, Freshness.MustBeFresh) } } + testScheduler.runCurrent() + + try { + awaitFromDefault { affectedFetchStarted.await() } + val clear = backgroundScope.async { store.clearNamespace(StoreNamespace("a")) } + testScheduler.runCurrent() + assertTrue(gated.namespaceDeleted.isCompleted, "clear must reach atomic bulk delete") + releaseAffectedFetch.complete(Unit) + val newEngineTail = backgroundScope.async { runCatching { store.get(betweenSweeps) } } + testScheduler.runCurrent() + awaitFromDefault { betweenSweepsFetchStarted.await() } + assertEquals("b/1-v2", store.get(unrelated, Freshness.MustBeFresh)) + gated.releaseDelete.complete(Unit) + awaitFromDefault { clear.await() } + assertMissingResult(awaitFromDefault { oldTail.await() }) + assertMissingResult(awaitFromDefault { newEngineTail.await() }) + assertNull(backing.reader(affected).first()) + assertNull(backing.reader(betweenSweeps).first()) + assertEquals("b/1-v2", backing.reader(unrelated).first()) + assertLocalOnlyMissing(store, affected) + assertLocalOnlyMissing(store, betweenSweeps) + } finally { + releaseAffectedFetch.complete(Unit) + gated.releaseDelete.complete(Unit) + } + store.close() + } + + @Test + fun clearAll_fencesCommitTailsAndEngineCreatedBetweenSweeps() = runTest { + val backing = InMemorySourceOfTruth() + val gated = PostDeleteGateSourceOfTruth(backing) + val affectedFetchStarted = CompletableDeferred() + val betweenSweepsFetchStarted = CompletableDeferred() + val releaseAffectedFetch = CompletableDeferred() + val counts = mutableMapOf() + val store = store { + fetcher { key -> + val id = key.canonicalId() + val call = counts.getOrElse(id) { 0 } + 1 + counts[id] = call + if (id == "1" && call == 2) { + affectedFetchStarted.complete(Unit) + releaseAffectedFetch.await() + } + if (id == "2") betweenSweepsFetchStarted.complete(Unit) + "$id-v$call" + } + persistence(gated) + } + val existing = TestKey("1") + val betweenSweeps = TestKey("2") + store.get(existing) + val oldTail = backgroundScope.async { runCatching { store.get(existing, Freshness.MustBeFresh) } } + testScheduler.runCurrent() + + try { + awaitFromDefault { affectedFetchStarted.await() } + val clear = backgroundScope.async { store.clearAll() } + testScheduler.runCurrent() + assertTrue(gated.allDeleted.isCompleted, "clearAll must reach atomic bulk delete") + releaseAffectedFetch.complete(Unit) + val newEngineTail = backgroundScope.async { runCatching { store.get(betweenSweeps) } } + testScheduler.runCurrent() + awaitFromDefault { betweenSweepsFetchStarted.await() } + gated.releaseDelete.complete(Unit) + awaitFromDefault { clear.await() } + assertMissingResult(awaitFromDefault { oldTail.await() }) + assertMissingResult(awaitFromDefault { newEngineTail.await() }) + assertNull(backing.reader(existing).first()) + assertNull(backing.reader(betweenSweeps).first()) + assertLocalOnlyMissing(store, existing) + assertLocalOnlyMissing(store, betweenSweeps) + } finally { + releaseAffectedFetch.complete(Unit) + gated.releaseDelete.complete(Unit) + } + store.close() + } + + private fun assertMissingResult(result: Result) { + val failure = assertIs(result.exceptionOrNull()) + assertIs(failure.error) + } + + // Preserve the real-time Default-dispatch hop (never virtual time) and let the suite-level + // runTest bound own cancellation. + private suspend fun awaitFromDefault(block: suspend () -> T): T = + withContext(Dispatchers.Default) { + block() + } + + private suspend fun assertLocalOnlyMissing( + store: Store, + key: K, + ) { + val failure = assertFailsWith { store.get(key, Freshness.LocalOnly) } + assertIs(failure.error) + } +} + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = 25.seconds, testBody = testBody) diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreTelemetryTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreTelemetryTest.kt new file mode 100644 index 000000000..975e52f51 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreTelemetryTest.kt @@ -0,0 +1,116 @@ +package org.mobilenativefoundation.store6.core + +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.seam.StoreTelemetry +import org.mobilenativefoundation.store6.core.seam.runtime +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Duration + +@OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) +class StoreTelemetryTest { + // events is mutated by 'start'/'success'/'failure' on the fetch coroutine (Dispatchers.Default) + // strictly BEFORE the FetchTicket outcome completes (the binding placement pin), and by + // 'serve'/'invalidated'/'cleared' on the caller. Every read below happens after the operation + // that resumed on that completion returned, so the list is ordered and visible -- no races. + private class RecordingTelemetry : StoreTelemetry { + val events = mutableListOf() + + override fun onFetchStarted(key: StoreKey) { + events += "start" + } + + override fun onFetchSucceeded( + key: StoreKey, + duration: Duration, + ) { + events += "success" + } + + override fun onFetchFailed( + key: StoreKey, + error: StoreError, + duration: Duration, + ) { + events += "failure" + } + + override fun onServe( + key: StoreKey, + origin: Origin, + ) { + events += "serve:$origin" + } + + override fun onInvalidated(key: StoreKey) { + events += "invalidated" + } + + override fun onCleared(key: StoreKey) { + events += "cleared" + } + } + + @Test + fun telemetry_observesFetchServeAndMaintenanceAltitude() = runTest { + val telemetry = RecordingTelemetry() + val store = store { + fetcher { "v" } + telemetry(telemetry) + } + store.get(TestKey("1")) + store.invalidate(TestKey("1")) + store.clear(TestKey("1")) + store.close() + // Deterministic BY the placement pin: success happens-before ticket completion, which + // happens-before get() resuming and calling onServe on this thread. + assertEquals( + listOf("start", "success", "serve:FETCHER", "invalidated", "cleared"), + telemetry.events, + ) + } + + @Test + fun telemetry_failureChannel() = runTest { + val telemetry = RecordingTelemetry() + val store = store { + fetcher { error("boom") } + telemetry(telemetry) + } + runCatching { store.get(TestKey("1")) } // Failed completes the ticket AFTER onFetchFailed ran. + store.close() + assertEquals(listOf("start", "failure"), telemetry.events) + } + + @Test + fun bulkClear_notifiesEachFirstSweepResidentExactlyOnce() = runTest { + val telemetry = RecordingTelemetry() + val store = store { + fetcher { key -> "v:${key.canonicalId()}" } + telemetry(telemetry) + } + store.get(TestKey("1")) + store.get(TestKey("2")) + telemetry.events.clear() + + store.clearNamespace(StoreNamespace("test")) + store.close() + + assertEquals(listOf("cleared", "cleared"), telemetry.events) + } + + @Test + fun unconfiguredTelemetry_behaviorIsUnchanged() = runTest { + val plain = store { fetcher { "v" } } + val instrumented = store { + fetcher { "v" } + telemetry(RecordingTelemetry()) + } + assertNull(plain.runtime()!!.telemetry) + assertEquals(plain.get(TestKey("1")), instrumented.get(TestKey("1"))) + plain.close() + instrumented.close() + // Allocation-count measurement lives in store6-benchmarks. + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreZeroConfigEquivalenceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreZeroConfigEquivalenceTest.kt new file mode 100644 index 000000000..5a010701f --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreZeroConfigEquivalenceTest.kt @@ -0,0 +1,209 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.test +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield +import org.mobilenativefoundation.store6.core.internal.DefaultFreshnessValidator +import org.mobilenativefoundation.store6.core.internal.InMemoryBookkeeper +import org.mobilenativefoundation.store6.core.internal.InMemorySourceOfTruth +import org.mobilenativefoundation.store6.core.internal.RealStore +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalStoreApi::class) +class StoreZeroConfigEquivalenceTest { + @Test + fun zeroConfig_and_expertConfig_observeIdenticalDefaults() = + runTest(timeout = 60.seconds) { + val clock = FakeWallClock(now = 1_000_000L) + val zeroConfigFetcher = ScriptedFetcher() + val zeroConfig = + store { + fetcher(zeroConfigFetcher::fetch) + wallClock(clock) + } as RealStore + + val expertConfigFetcher = ScriptedFetcher() + val expertConfig = + store { + fetcher(expertConfigFetcher::fetch) + wallClock(clock) + persistence(InMemorySourceOfTruth()) + bookkeeper(InMemoryBookkeeper()) + freshnessValidator(DefaultFreshnessValidator) + maxIdleKeys(128) + } as RealStore + + try { + val zeroConfigTrace = scenario(zeroConfig, zeroConfigFetcher) + assertEquals(EXPECTED_TRACE, zeroConfigTrace, "zero-config trace") + assertEquals(4, zeroConfigFetcher.fetchCount, "zero-config total fetches") + + val expertConfigTrace = scenario(expertConfig, expertConfigFetcher) + assertEquals(EXPECTED_TRACE, expertConfigTrace, "expert-config trace") + assertEquals(4, expertConfigFetcher.fetchCount, "expert-config total fetches") + + assertEquals(zeroConfigTrace, expertConfigTrace) + } finally { + zeroConfigFetcher.releaseSecondFetch.complete(Unit) + expertConfigFetcher.releaseSecondFetch.complete(Unit) + zeroConfig.close() + expertConfig.close() + zeroConfig.awaitTerminationForTest() + expertConfig.awaitTerminationForTest() + } + } + + @Test + fun defaultMaxIdleKeys_matchesExplicit128Cap() = + runTest(timeout = 60.seconds) { + val zeroConfig = + store { + fetcher { key -> "value:${key.canonicalId()}" } + } as RealStore + val expertConfig = + store { + fetcher { key -> "value:${key.canonicalId()}" } + maxIdleKeys(128) + } as RealStore + + try { + assertIdleCap128(zeroConfig, "zero-config") + assertIdleCap128(expertConfig, "expert-config") + } finally { + zeroConfig.close() + expertConfig.close() + zeroConfig.awaitTerminationForTest() + expertConfig.awaitTerminationForTest() + } + } + + private suspend fun scenario( + store: Store, + scriptedFetcher: ScriptedFetcher, + ): List { + val key = TestKey("ac6") + val observations = mutableListOf() + + observations += store.stream(key).take(2).toList().map(::label) + observations += "warm=${store.get(key)}" + store.invalidate(key) + observations += "after-invalidate=${store.get(key)}" + scriptedFetcher.secondFetchStarted.await() + + store.stream(key, Freshness.LocalOnly).test { + val stale = assertIs>(awaitItem()) + assertEquals(STALE_REFRESHING_LABEL, label(stale)) + observations += label(stale) + + scriptedFetcher.releaseSecondFetch.complete(Unit) + + var fresh = assertIs>(awaitItem()) + while (fresh.value == "v1:ac6") { + assertEquals(STALE_REFRESHING_LABEL, label(fresh)) + fresh = assertIs(awaitItem()) + } + assertEquals(FRESH_AFTER_INVALIDATE_LABEL, label(fresh)) + observations += label(fresh) + cancelAndIgnoreRemainingEvents() + } + assertEquals(2, scriptedFetcher.fetchCount, "post-invalidate refresh fetches") + + observations += "must-be-fresh=${store.get(key, Freshness.MustBeFresh)}" + assertEquals(3, scriptedFetcher.fetchCount, "MustBeFresh fetches") + + val missing = + assertFailsWith { + store.get(TestKey("missing"), Freshness.LocalOnly) + } + val missingError = assertIs(missing.error) + observations += "local-only=${missingError::class.simpleName}" + assertEquals(3, scriptedFetcher.fetchCount, "LocalOnly must not fetch") + + store.clear(key) + observations += "after-clear=${store.get(key)}" + assertEquals(4, scriptedFetcher.fetchCount, "clear/get fetches") + + return observations + } + + private suspend fun assertIdleCap128( + store: RealStore, + configLabel: String, + ) { + repeat(129) { index -> + val key = TestKey("idle-$index") + assertEquals("value:${key.canonicalId()}", store.get(key)) + } + + // Preserve the real-time Default-dispatch hop; the tests' explicit runTest bound owns + // cancellation. + withContext(Dispatchers.Default) { + while ( + store.createdEngineCountForTest() != 129L || + store.createdEngineCountForTest() - store.destroyedEngineCountForTest() != + store.residentEngineCountForTest().toLong() || + store.idleEngineCountForTest() != store.residentEngineCountForTest() + ) { + yield() + } + } + + assertEquals(129L, store.createdEngineCountForTest(), "$configLabel created engines") + assertEquals(128, store.residentEngineCountForTest(), "$configLabel resident engines") + assertEquals(128, store.idleEngineCountForTest(), "$configLabel idle engines") + assertEquals(1L, store.destroyedEngineCountForTest(), "$configLabel destroyed engines") + } + + private fun label(result: StoreResult): String = + when (result) { + is StoreResult.Data -> + "Data(${result.value},${result.origin},${result.isStale},${result.refreshing})" + is StoreResult.Loading -> "Loading" + is StoreResult.Revalidated -> "Revalidated" + is StoreResult.Error -> + "Error(${result.error::class.simpleName},${result.servedStale})" + } + + private class ScriptedFetcher { + val secondFetchStarted = CompletableDeferred() + val releaseSecondFetch = CompletableDeferred() + var fetchCount: Int = 0 + private set + + suspend fun fetch(key: TestKey): String { + val call = ++fetchCount + if (call == 2) { + secondFetchStarted.complete(Unit) + releaseSecondFetch.await() + } + return "v$call:${key.canonicalId()}" + } + } + + private companion object { + const val STALE_REFRESHING_LABEL = "Data(v1:ac6,MEMORY,true,true)" + const val FRESH_AFTER_INVALIDATE_LABEL = "Data(v2:ac6,FETCHER,false,false)" + + val EXPECTED_TRACE = + listOf( + "Loading", + "Data(v1:ac6,FETCHER,false,false)", + "warm=v1:ac6", + "after-invalidate=v1:ac6", + STALE_REFRESHING_LABEL, + FRESH_AFTER_INVALIDATE_LABEL, + "must-be-fresh=v3:ac6", + "local-only=Missing", + "after-clear=v4:ac6", + ) + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/TestBarriers.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/TestBarriers.kt new file mode 100644 index 000000000..8b11b6b1f --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/TestBarriers.kt @@ -0,0 +1,23 @@ +package org.mobilenativefoundation.store6.core + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeSource + +internal suspend fun awaitUntil( + timeout: Duration = 10.seconds, + condition: suspend () -> Boolean, +) { + withContext(Dispatchers.Default) { + val started = TimeSource.Monotonic.markNow() + while (!condition()) { + check(started.elapsedNow() < timeout) { + "Condition was not satisfied within $timeout." + } + delay(20) + } + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/TestClocks.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/TestClocks.kt new file mode 100644 index 000000000..7daff92a6 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/TestClocks.kt @@ -0,0 +1,41 @@ +package org.mobilenativefoundation.store6.core + +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import org.mobilenativefoundation.store6.core.seam.WallClock + +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +internal class FakeWallClock(var now: Long) : WallClock { + override fun nowEpochMillis(): Long = now +} + +@OptIn(ExperimentalStoreApi::class) +internal fun storeWith( + clock: WallClock? = null, + bookkeeper: Bookkeeper? = null, + configure: StoreBuilder.() -> Unit, +): Store = + StoreBuilder().apply { + clock?.let { this.wallClock(it) } + bookkeeper?.let { this.bookkeeper(it) } + configure() + }.build() + +/** + * Core's conformance base keeps exercising the actual zero-config persistence implementation. + * The borrowed SQLDelight compilation re-derives this support seam with a public-API equivalent. + */ +@OptIn(ExperimentalStoreApi::class) +internal fun defaultConformanceSourceOfTruth(): SourceOfTruth = + org.mobilenativefoundation.store6.core.internal.InMemorySourceOfTruth() + +/** + * Test-support shutdown: close, then JOIN the store's engine job so no engine coroutine from this + * test outlives it on Dispatchers.Default. Cleanup-only — never load-bearing for assertions. + * The borrowed-suite re-derivation in store6-sqldelight cannot reach core internals and settles + * with close() alone (documented asymmetry; see BorrowedSuiteSupport.kt). + */ +internal suspend fun Store<*, *>.closeAndSettleForTest() { + close() + (this as? org.mobilenativefoundation.store6.core.internal.RealStore<*, *>)?.awaitTerminationForTest() +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/TestKey.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/TestKey.kt new file mode 100644 index 000000000..61cc51556 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/TestKey.kt @@ -0,0 +1,34 @@ +package org.mobilenativefoundation.store6.core + +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth + +class TestKey(private val id: String) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("test") + override fun canonicalId(): String = id +} + +/** + * Bulk-deletion adapter for fixtures that intentionally model exactly one row in the `test` + * namespace and ignore key identity for every reader and mutation. + * + * These fixtures cannot run the source-of-truth contract kit because they do not provide key + * isolation. Their namespace/global operations delete that one modeled row through the fixture's + * existing per-key behavior, preserving any deterministic gate or fault under test. + */ +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +internal interface SingleRowTestSourceOfTruth : SourceOfTruth { + override suspend fun deleteNamespace(namespace: StoreNamespace) { + if (namespace.value == TEST_NAMESPACE) { + delete(BULK_DELETE_KEY) + } + } + + override suspend fun deleteAll() { + delete(BULK_DELETE_KEY) + } + + private companion object { + const val TEST_NAMESPACE = "test" + val BULK_DELETE_KEY = TestKey("bulk-delete") + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/docs/GuideSnippetCompilation.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/docs/GuideSnippetCompilation.kt new file mode 100644 index 000000000..a7b4b1629 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/docs/GuideSnippetCompilation.kt @@ -0,0 +1,60 @@ +@file:OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) +@file:Suppress("unused") + +package org.mobilenativefoundation.store6.core.docs + +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.store +import org.mobilenativefoundation.store6.core.seam.Fetcher +import org.mobilenativefoundation.store6.core.seam.FetcherResult + +private class UserKey( + val id: String, +) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("users") + + override fun canonicalId(): String = id +} + +private data class User( + val id: String, + val name: String, +) + +private class UserApi { + suspend fun getUser(id: String): User = User(id, "User $id") +} + +private val api = UserApi() + +private val conditionalUserFetcher = + object : Fetcher { + override suspend fun fetch(key: UserKey, etag: String?): FetcherResult = + FetcherResult.Success(api.getUser(key.id), etag) + } + +private fun compileFetcherInstallPoints() { + // docs:snippet:guides-fetchers-install-points + val plainUsers = store { + fetcher { key -> api.getUser(key.id) } + } + + val resultUsers = store { + fetcherOfResult { key -> + FetcherResult.Success(api.getUser(key.id)) + } + } + + @OptIn(ExperimentalStoreApi::class) + val conditionalUsers = store { + fetcher(conditionalUserFetcher) + } + // docs:snippet:end + + plainUsers.close() + resultUsers.close() + conditionalUsers.close() +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/BookkeeperOrderingConformanceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/BookkeeperOrderingConformanceTest.kt new file mode 100644 index 000000000..2c85d59e2 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/BookkeeperOrderingConformanceTest.kt @@ -0,0 +1,543 @@ +package org.mobilenativefoundation.store6.core.internal + +import app.cash.turbine.test +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.FakeWallClock +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreException +import org.mobilenativefoundation.store6.core.StoreMeta +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.TestKey +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import org.mobilenativefoundation.store6.core.SingleRowTestSourceOfTruth +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.core.seam.KeyStatus +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +class BookkeeperOrderingConformanceTest { + private val key = TestKey("1") + private val keyId = KeyId.from(key) + + @Test + fun clearCannotBeOvertakenByAnOlderSuccessfulCommit() = runTest { + val successGate = MutationGate() + val bookkeeper = GateableBookkeeper(successGate = successGate) + val engine = engine(bookkeeper) { FetcherResult.Success("v1", etag = "e1") } + + val read = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { engine.get(Freshness.CachedOrFetch) } + } + testScheduler.runCurrent() + successGate.entered.await() + + val clear = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.clear() + } + testScheduler.runCurrent() + + successGate.release() + testScheduler.runCurrent() + read.await() + clear.await() + + assertNull(bookkeeper.status(key)) + } + + @Test + fun confirmFresh_successTailBlocksSameKeyClearUntilBookkeepingCompletes() = runTest { + val bookkeeper = OrderedConfirmFreshBookkeeper() + val engine = engine(bookkeeper) { FetcherResult.Success("v1", etag = "e1") } + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + assertEquals(1, bookkeeper.successCalls) + assertEquals(listOf("success1:start", "success1:end"), bookkeeper.events) + + val confirmation = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.confirmFresh(etag = "confirmed") + } + testScheduler.runCurrent() + + var clearPassedWhileSuccessBlocked = false + var eventsWhileSuccessBlocked: List = emptyList() + val clear = + try { + withTimeout(1_000L) { bookkeeper.secondSuccessStarted.await() } + assertEquals(2, bookkeeper.successCalls) + assertEquals(0, bookkeeper.forgetCalls) + + val pending = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.clear() + } + testScheduler.runCurrent() + clearPassedWhileSuccessBlocked = pending.isCompleted + eventsWhileSuccessBlocked = bookkeeper.events.toList() + pending + } finally { + bookkeeper.releaseSecondSuccess.complete(Unit) + } + + confirmation.await() + clear.await() + + assertFalse( + clearPassedWhileSuccessBlocked, + "same-key clear passed the blocked confirmFresh tail: $eventsWhileSuccessBlocked", + ) + assertEquals( + listOf("success1:start", "success1:end", "success2:start"), + eventsWhileSuccessBlocked, + ) + assertEquals( + listOf( + "success1:start", + "success1:end", + "success2:start", + "success2:end", + "forget:start", + "forget:end", + ), + bookkeeper.events, + ) + assertEquals(2, bookkeeper.successCalls) + assertEquals(1, bookkeeper.forgetCalls) + assertNull(bookkeeper.status(key)) + val missing = + assertFailsWith { + engine.get(Freshness.LocalOnly) + } + assertIs(missing.error) + } + + @Test + fun serverDeleteCannotEraseANewerSuccessfulCommit() = runTest { + val forgetGate = MutationGate() + val bookkeeper = GateableBookkeeper(forgetGate = forgetGate) + var calls = 0 + val engine = + engine(bookkeeper) { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> FetcherResult.Deleted + 3 -> FetcherResult.Success("v2", etag = "e2") + else -> error("unexpected fetch call $calls") + } + } + + val seed = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.get(Freshness.CachedOrFetch) + } + testScheduler.runCurrent() + assertEquals("v1", seed.await()) + + val deletion = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { engine.get(Freshness.MustBeFresh) } + } + testScheduler.runCurrent() + forgetGate.entered.await() + + val replacement = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.get(Freshness.CachedOrFetch) + } + testScheduler.runCurrent() + + forgetGate.release() + testScheduler.runCurrent() + deletion.await() + assertEquals("v2", replacement.await()) + assertEquals("e2", bookkeeper.status(key)?.meta?.etag) + } + + @Test + fun failureAfterClearCannotRecreateFailureStatus() = runTest { + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + val bookkeeper = GateableBookkeeper() + val engine = + engine(bookkeeper) { + fetchStarted.complete(Unit) + releaseFetch.await() + throw IllegalStateException("boom") + } + + val read = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { engine.get(Freshness.CachedOrFetch) } + } + testScheduler.runCurrent() + fetchStarted.await() + + val clear = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.clear() + } + testScheduler.runCurrent() + clear.await() + + releaseFetch.complete(Unit) + testScheduler.runCurrent() + read.await() + + assertNull(bookkeeper.status(key)) + } + + @Test + fun failureTimestampIsCapturedBeforeOrderedBookkeepingWait() = runTest { + val markGate = MutationGate() + val failureFetched = CompletableDeferred() + val bookkeeper = GateableBookkeeper(markGate = markGate) + val clock = FakeWallClock(now = 0L) + val sot = InMemorySourceOfTruth() + sot.write(key, "v1") + var calls = 0 + val engine = + engine(bookkeeper, clock, sot = sot) { + calls += 1 + failureFetched.complete(Unit) + FetcherResult.Error(IllegalStateException("boom")) + } + + assertEquals("v1", engine.get(Freshness.LocalOnly)) + assertEquals(0, calls, "LocalOnly hydration must not fetch") + + val orderedInvalidation = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.invalidate() + } + testScheduler.runCurrent() + markGate.entered.await() + + try { + clock.now = 10L + val failedRefresh = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { engine.get(Freshness.MustBeFresh) } + } + testScheduler.runCurrent() + failureFetched.await() + + clock.now = 99L + markGate.release() + testScheduler.runCurrent() + orderedInvalidation.await() + failedRefresh.await() + } finally { + markGate.release() + } + + assertEquals(10L, bookkeeper.status(key)?.lastFailureAtEpochMillis) + assertEquals(1, calls) + } + + @Test + fun engineCancellationReleasesOrdinaryWriteFailureBookkeeping() = runTest { + val failureGate = MutationGate() + val bookkeeper = GateableBookkeeper(failureGate = failureGate) + val sot = ThrowingWriteSourceOfTruth() + val engineJob = Job(backgroundScope.coroutineContext[Job]) + val engineScope = CoroutineScope(backgroundScope.coroutineContext + engineJob) + val engine = + engine(bookkeeper, engineScope = engineScope, sot = sot) { + FetcherResult.Success("v1", etag = "e1") + } + val read = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { engine.get(Freshness.CachedOrFetch) } + } + testScheduler.runCurrent() + sot.writeAttempted.await() + failureGate.entered.await() + + try { + engineJob.cancel() + val clear = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.clear() + } + testScheduler.runCurrent() + + assertTrue(clear.isCompleted, "engine cancellation must release writeLock") + clear.await() + } finally { + failureGate.release() + testScheduler.runCurrent() + } + read.await() + } + + @Test + fun engineCancellationReleasesBlockedFailureBookkeeping() = runTest { + val failureGate = MutationGate() + val bookkeeper = GateableBookkeeper(failureGate = failureGate) + val engineJob = Job(backgroundScope.coroutineContext[Job]) + val engineScope = CoroutineScope(backgroundScope.coroutineContext + engineJob) + val engine = + engine(bookkeeper, engineScope = engineScope) { + FetcherResult.Error(IllegalStateException("boom")) + } + val read = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { engine.get(Freshness.CachedOrFetch) } + } + testScheduler.runCurrent() + failureGate.entered.await() + + try { + engineJob.cancel() + val clear = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.clear() + } + testScheduler.runCurrent() + + assertTrue(clear.isCompleted, "engine cancellation must release bookkeepingLock") + clear.await() + } finally { + failureGate.release() + testScheduler.runCurrent() + } + read.await() + } + + @Test + fun invalidate_marksBeforeEpochSignal_andBlocksSameKeyCommit() = runTest { + val events = mutableListOf() + val markGate = MutationGate() + val durableBookkeeper = GateableBookkeeper(markGate = markGate, events = events) + val durableSot = InMemorySourceOfTruth() + val secondFetchEntered = CompletableDeferred() + var calls = 0 + val engine = + engine(durableBookkeeper, sot = durableSot) { + val call = ++calls + if (call == 2) secondFetchEntered.complete(Unit) + FetcherResult.Success("v$call", etag = "e$call") + } + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + val initialEpoch = engine.state.value.staleEpoch + + engine.stream(Freshness.CachedOrFetch).test { + assertEquals("v1", assertIs>(awaitItem()).value) + val invalidation = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { engine.invalidate() } + testScheduler.runCurrent() + try { + assertTrue(markGate.entered.isCompleted, "durable mark must begin before epoch signal") + assertEquals(initialEpoch, engine.state.value.staleEpoch) + expectNoEvents() + + val competingCommit = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.get(Freshness.MustBeFresh) + } + withTimeout(1_000) { secondFetchEntered.await() } + assertEquals(2, calls, "network may finish while its durable commit is fenced") + assertEquals(listOf("success"), events, "second durable commit must still be blocked") + assertEquals("v1", durableSot.reader(key).first()) + expectNoEvents() + + markGate.release() + withTimeout(1_000) { invalidation.await() } + assertEquals(initialEpoch + 1L, engine.state.value.staleEpoch) + assertEquals("v2", withTimeout(1_000) { competingCommit.await() }) + while (true) { + val item = awaitItem() + if (item is StoreResult.Data && item.value == "v2") break + } + assertEquals(listOf("success", "markStale", "success"), events) + assertEquals(2, calls, "the ordered mark/epoch/success cycle must single-flight") + cancelAndIgnoreRemainingEvents() + } finally { + markGate.release() + } + } + } + + private fun TestScope.engine( + bookkeeper: Bookkeeper, + clock: FakeWallClock = FakeWallClock(now = 0L), + engineScope: CoroutineScope = backgroundScope, + sot: SourceOfTruth = InMemorySourceOfTruth(), + fetcher: suspend (TestKey) -> FetcherResult, + ): KeyEngine = + KeyEngine( + key = key, + keyId = keyId, + fetcher = ResultFetcher(fetcher), + sot = sot, + bookkeeper = bookkeeper, + validator = DefaultFreshnessValidator, + wallClock = clock, + engineScope = engineScope, + ) + + private class MutationGate { + val entered = CompletableDeferred() + private val released = CompletableDeferred() + + suspend fun pause() { + entered.complete(Unit) + released.await() + } + + fun release() { + released.complete(Unit) + } + } + + private class GateableBookkeeper( + successGate: MutationGate? = null, + private val failureGate: MutationGate? = null, + private val forgetGate: MutationGate? = null, + private val markGate: MutationGate? = null, + private val events: MutableList? = null, + ) : Bookkeeper { + private val delegate = InMemoryBookkeeper() + private var successGate = successGate + + fun gateNextSuccess(gate: MutationGate) { + successGate = gate + } + + override suspend fun recordSuccess( + key: StoreKey, + meta: StoreMeta, + ) { + successGate?.also { successGate = null }?.pause() + events?.add("success") + delegate.recordSuccess(key, meta) + } + + override suspend fun recordFailure( + key: StoreKey, + atEpochMillis: Long, + ) { + failureGate?.pause() + delegate.recordFailure(key, atEpochMillis) + } + + override suspend fun status(key: StoreKey): KeyStatus? = delegate.status(key) + + override suspend fun forget(key: StoreKey) { + forgetGate?.pause() + delegate.forget(key) + } + + override suspend fun markStale(key: StoreKey) { + markGate?.pause() + events?.add("markStale") + delegate.markStale(key) + } + + override suspend fun advanceStaleWatermark(namespace: StoreNamespace) = + delegate.advanceStaleWatermark(namespace) + + override suspend fun advanceGlobalStaleWatermark() = + delegate.advanceGlobalStaleWatermark() + + override suspend fun forgetNamespace(namespace: StoreNamespace) = + delegate.forgetNamespace(namespace) + + override suspend fun forgetAll() = delegate.forgetAll() + } + + private class OrderedConfirmFreshBookkeeper : Bookkeeper { + private val delegate = InMemoryBookkeeper() + val secondSuccessStarted = CompletableDeferred() + val releaseSecondSuccess = CompletableDeferred() + val events = mutableListOf() + var successCalls = 0 + private set + var forgetCalls = 0 + private set + + override suspend fun recordSuccess( + key: StoreKey, + meta: StoreMeta, + ) { + val call = ++successCalls + events += "success$call:start" + if (call == 2) { + secondSuccessStarted.complete(Unit) + releaseSecondSuccess.await() + } + delegate.recordSuccess(key, meta) + events += "success$call:end" + } + + override suspend fun recordFailure( + key: StoreKey, + atEpochMillis: Long, + ) = delegate.recordFailure(key, atEpochMillis) + + override suspend fun status(key: StoreKey): KeyStatus? = delegate.status(key) + + override suspend fun forget(key: StoreKey) { + forgetCalls += 1 + events += "forget:start" + delegate.forget(key) + events += "forget:end" + } + + override suspend fun markStale(key: StoreKey) = delegate.markStale(key) + + override suspend fun advanceStaleWatermark(namespace: StoreNamespace) = + delegate.advanceStaleWatermark(namespace) + + override suspend fun advanceGlobalStaleWatermark() = + delegate.advanceGlobalStaleWatermark() + + override suspend fun forgetNamespace(namespace: StoreNamespace) = + delegate.forgetNamespace(namespace) + + override suspend fun forgetAll() = delegate.forgetAll() + } + + private class ThrowingWriteSourceOfTruth : SingleRowTestSourceOfTruth { + private val value = MutableStateFlow(null) + val writeAttempted = CompletableDeferred() + + override fun reader(key: TestKey): Flow = value + + override suspend fun write( + key: TestKey, + value: String, + ) { + writeAttempted.complete(Unit) + throw IllegalStateException("write failed") + } + + override suspend fun delete(key: TestKey) { + value.value = null + } + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/ConflateLatestDataTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/ConflateLatestDataTest.kt new file mode 100644 index 000000000..836090b4b --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/ConflateLatestDataTest.kt @@ -0,0 +1,365 @@ +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +class ConflateLatestDataTest { + + @Test + fun slowCollector_getsLatestDataAndEveryLifecycleSignalBeforeCompletion() = runTest { + val firstDataSeen = CompletableDeferred() + val releaseCollector = CompletableDeferred() + val upstreamCompleted = CompletableDeferred() + val received = mutableListOf>() + + val upstream = flow> { + emit(data(1)) + firstDataSeen.await() + emit(data(2)) + emit(data(3)) + emit(StoreResult.Loading()) + emit(StoreResult.Loading()) + emit(StoreResult.Revalidated(age = Duration.ZERO)) + emit( + StoreResult.Error( + error = StoreError.Fetch(message = "fetch failed", cause = null), + servedStale = true, + ), + ) + upstreamCompleted.complete(Unit) + } + + val collector = backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + upstream.conflateLatestData().collect { result -> + received += result + if (result is StoreResult.Data && result.value == 1) { + firstDataSeen.complete(Unit) + releaseCollector.await() + } + } + } + + firstDataSeen.await() + upstreamCompleted.await() + assertTrue(collector.isActive) + + releaseCollector.complete(Unit) + collector.join() + + assertEquals( + listOf("data:1", "data:3", "loading", "revalidated", "error"), + received.map(::label), + ) + } + + @Test + fun blockedCollector_queueBoundedAcrossManyRevalidationCycles() = runTest { + val firstDataSeen = CompletableDeferred() + val releaseCollector = CompletableDeferred() + val upstreamCompleted = CompletableDeferred() + val received = mutableListOf>() + + val upstream = flow> { + emit(data(0)) + firstDataSeen.await() + repeat(1_000) { cycle -> + emit(data(cycle + 1)) + emit(StoreResult.Loading()) + emit( + StoreResult.Error( + error = StoreError.Fetch(message = "e$cycle", cause = null), + servedStale = false, + ), + ) + emit(StoreResult.Revalidated(age = Duration.ZERO)) + } + emit(data(9_999)) + upstreamCompleted.complete(Unit) + } + + val collector = backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + upstream.conflateLatestData().collect { result -> + received += result + if (result is StoreResult.Data && result.value == 0) { + firstDataSeen.complete(Unit) + releaseCollector.await() // park: 4_001 further emissions coalesce in the queue + } + } + } + + firstDataSeen.await() + upstreamCompleted.await() + releaseCollector.complete(Unit) + collector.join() + + // First delivered frame + at most one queued element per kind (<= 4) = <= 5 total. + assertTrue( + received.size <= 5, + "blocked collector saw ${received.size} results; bound is O(kinds)", + ) + assertEquals( + 9_999, + (received.last { it is StoreResult.Data } as StoreResult.Data).value, + ) + assertTrue(received.any { it is StoreResult.Error }) + assertTrue(received.any { it is StoreResult.Revalidated }) + assertTrue(received.any { it is StoreResult.Loading }) + } + + @Test + fun blockedCollector_removeAndAppendKeepsLatestPayloadsInRelativeOccurrenceOrder() = runTest { + val firstDataSeen = CompletableDeferred() + val releaseCollector = CompletableDeferred() + val upstreamCompleted = CompletableDeferred() + val received = mutableListOf>() + + val upstream = flow> { + emit(data(0)) + firstDataSeen.await() + emit( + StoreResult.Error( + error = StoreError.Fetch(message = "old-error", cause = null), + servedStale = false, + ), + ) + emit(StoreResult.Revalidated(age = 1.seconds)) + emit(StoreResult.Loading()) + emit( + StoreResult.Error( + error = StoreError.Fetch(message = "latest-error", cause = null), + servedStale = true, + ), + ) + emit(StoreResult.Revalidated(age = 2.seconds)) + emit(data(42)) + upstreamCompleted.complete(Unit) + } + + val collector = backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + upstream.conflateLatestData().collect { result -> + received += result + if (result is StoreResult.Data && result.value == 0) { + firstDataSeen.complete(Unit) + releaseCollector.await() + } + } + } + + firstDataSeen.await() + upstreamCompleted.await() + releaseCollector.complete(Unit) + collector.join() + + assertEquals( + listOf("data:0", "loading", "error", "revalidated", "data:42"), + received.map(::label), + ) + val latestError = received.filterIsInstance().single() + assertEquals("latest-error", (latestError.error as StoreError.Fetch).message) + assertTrue(latestError.servedStale) + assertEquals( + 2.seconds, + received.filterIsInstance().single().age, + ) + } + + @Test + fun immediateCollector_getsEverySynchronousDataEmission() = runTest { + val received = mutableListOf>() + + val upstream = flow> { + emit(data(1)) + emit(data(2)) + emit(data(3)) + } + + upstream.conflateLatestData().collect { result -> + received += result + } + + assertEquals( + listOf("data:1", "data:2", "data:3"), + received.map(::label), + ) + } + + @Test + fun eachCollector_hasIndependentConflationState() = runTest { + var subscriptions = 0 + val upstream = flow> { + subscriptions += 1 + emit(data(subscriptions)) + } + val first = mutableListOf>() + val second = mutableListOf>() + val conflated = upstream.conflateLatestData() + + conflated.collect(first::add) + conflated.collect(second::add) + + assertEquals(listOf("data:1"), first.map(::label)) + assertEquals(listOf("data:2"), second.map(::label)) + } + + @Test + fun cancellingCollector_cancelsUpstream() = runTest { + val upstreamStarted = CompletableDeferred() + val upstreamCancelled = CompletableDeferred() + val upstream = flow> { + upstreamStarted.complete(Unit) + try { + awaitCancellation() + } finally { + upstreamCancelled.complete(Unit) + } + } + + val collector = backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + upstream.conflateLatestData().collect() + } + upstreamStarted.await() + + collector.cancelAndJoin() + + upstreamCancelled.await() + } + + @Test + fun explicitUpstreamCancellation_drainsQueuedValuesThenPropagatesExactFailure() = runTest { + val firstDataSeen = CompletableDeferred() + val releaseCollector = CompletableDeferred() + val upstreamFinished = CompletableDeferred() + val explicitCancellation = CancellationException("explicit upstream cancellation") + val received = mutableListOf>() + + val collector = backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { + flow> { + try { + emit(data(1)) + firstDataSeen.await() + emit(data(2)) + emit(StoreResult.Loading()) + throw explicitCancellation + } finally { + upstreamFinished.complete(Unit) + } + }.conflateLatestData().collect { result -> + received += result + if (result is StoreResult.Data && result.value == 1) { + firstDataSeen.complete(Unit) + releaseCollector.await() + } + } + }.exceptionOrNull() + } + + firstDataSeen.await() + upstreamFinished.await() + releaseCollector.complete(Unit) + + assertSame(explicitCancellation, collector.await()) + assertEquals(listOf("data:1", "data:2", "loading"), received.map(::label)) + } + + @Test + fun upstreamFailure_drainsQueuedValuesThenPropagatesExactFailure() = runTest { + val firstDataSeen = CompletableDeferred() + val releaseCollector = CompletableDeferred() + val upstreamFinished = CompletableDeferred() + val upstreamFailure = IllegalStateException("upstream failed") + val received = mutableListOf>() + + val collector = backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { + flow> { + try { + emit(data(1)) + firstDataSeen.await() + emit(data(2)) + emit(StoreResult.Revalidated(age = Duration.ZERO)) + throw upstreamFailure + } finally { + upstreamFinished.complete(Unit) + } + }.conflateLatestData().collect { result -> + received += result + if (result is StoreResult.Data && result.value == 1) { + firstDataSeen.complete(Unit) + releaseCollector.await() + } + } + }.exceptionOrNull() + } + + firstDataSeen.await() + upstreamFinished.await() + releaseCollector.complete(Unit) + + assertSame(upstreamFailure, collector.await()) + assertEquals(listOf("data:1", "data:2", "revalidated"), received.map(::label)) + } + + @Test + fun downstreamCollectorFailure_cancelsUpstream() = runTest { + val upstreamStarted = CompletableDeferred() + val upstreamCancelled = CompletableDeferred() + val collectorFailure = IllegalStateException("collector failed") + val upstream = flow> { + upstreamStarted.complete(Unit) + try { + emit(data(1)) + awaitCancellation() + } finally { + upstreamCancelled.complete(Unit) + } + } + + val observedFailure = runCatching { + upstream.conflateLatestData().collect { + throw collectorFailure + } + }.exceptionOrNull() + + upstreamStarted.await() + upstreamCancelled.await() + assertTrue( + observedFailure === collectorFailure || observedFailure?.cause === collectorFailure, + "collector failure was not propagated", + ) + } + + private fun data(value: Int): StoreResult.Data = + StoreResult.Data( + value = value, + origin = Origin.MEMORY, + age = Duration.ZERO, + isStale = false, + refreshing = false, + ) + + private fun label(result: StoreResult): String = + when (result) { + is StoreResult.Data -> "data:${result.value}" + is StoreResult.Loading -> "loading" + is StoreResult.Revalidated -> "revalidated" + is StoreResult.Error -> "error" + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/FreshnessValidatorTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/FreshnessValidatorTest.kt new file mode 100644 index 000000000..c479a21bd --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/FreshnessValidatorTest.kt @@ -0,0 +1,334 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.core.internal + +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.StoreMeta +import org.mobilenativefoundation.store6.core.seam.FetchPlan +import org.mobilenativefoundation.store6.core.seam.FreshnessContext +import org.mobilenativefoundation.store6.core.seam.KeyStatus +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertSame +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes + +class FreshnessValidatorTest { + @Test + fun localOnly_alwaysSkipsAbsentAndStaleResident() { + assertSame( + FetchPlan.Skip, + plan( + hasResidentValue = false, + meta = null, + epochStale = false, + freshness = Freshness.LocalOnly, + ), + ) + assertSame( + FetchPlan.Skip, + plan( + hasResidentValue = true, + meta = meta(writtenAtEpochMillis = 0L), + epochStale = true, + freshness = Freshness.LocalOnly, + ), + ) + } + + @Test + fun mustBeFresh_alwaysFetchesWithoutServingResident() { + assertFetch( + plan( + hasResidentValue = false, + meta = null, + epochStale = false, + freshness = Freshness.MustBeFresh, + ), + servesResident = false, + ) + assertFetch( + plan( + hasResidentValue = true, + meta = meta(writtenAtEpochMillis = 0L), + epochStale = true, + freshness = Freshness.MustBeFresh, + ), + servesResident = false, + ) + } + + @Test + fun cachedOrFetch_skipsFreshFetchesStaleResidentAndFetchesAbsent() { + assertSame( + FetchPlan.Skip, + plan( + hasResidentValue = true, + meta = meta(writtenAtEpochMillis = 0L), + epochStale = false, + freshness = Freshness.CachedOrFetch, + ), + ) + assertFetch( + plan( + hasResidentValue = true, + meta = meta(writtenAtEpochMillis = 0L), + epochStale = true, + freshness = Freshness.CachedOrFetch, + ), + servesResident = true, + ) + assertFetch( + plan( + hasResidentValue = false, + meta = null, + epochStale = false, + freshness = Freshness.CachedOrFetch, + ), + servesResident = false, + ) + } + + @Test + fun staleIfError_usesCachedOrFetchPlanning() { + assertSame( + FetchPlan.Skip, + plan( + hasResidentValue = true, + meta = meta(writtenAtEpochMillis = 0L), + epochStale = false, + freshness = Freshness.StaleIfError, + ), + ) + assertFetch( + plan( + hasResidentValue = true, + meta = meta(writtenAtEpochMillis = 0L), + epochStale = true, + freshness = Freshness.StaleIfError, + ), + servesResident = true, + ) + assertFetch( + plan( + hasResidentValue = false, + meta = null, + epochStale = false, + freshness = Freshness.StaleIfError, + ), + servesResident = false, + ) + } + + @Test + fun maxAge_enforcesBoundMetadataAndEpoch() { + val freshness = Freshness.MaxAge(5.minutes) + + assertSame( + FetchPlan.Skip, + plan( + hasResidentValue = true, + meta = meta(writtenAtEpochMillis = 540_000L), + epochStale = false, + freshness = freshness, + nowEpochMillis = 600_000L, + ), + ) + assertFetch( + plan( + hasResidentValue = true, + meta = meta(writtenAtEpochMillis = 0L), + epochStale = false, + freshness = freshness, + nowEpochMillis = 600_000L, + ), + servesResident = false, + ) + assertFetch( + plan( + hasResidentValue = true, + meta = meta(writtenAtEpochMillis = Long.MIN_VALUE), + epochStale = false, + freshness = freshness, + nowEpochMillis = Long.MAX_VALUE, + ), + servesResident = false, + ) + assertFetch( + plan( + hasResidentValue = true, + meta = null, + epochStale = false, + freshness = freshness, + ), + servesResident = false, + ) + assertFetch( + plan( + hasResidentValue = true, + meta = meta(writtenAtEpochMillis = 540_000L), + epochStale = true, + freshness = freshness, + nowEpochMillis = 600_000L, + ), + servesResident = false, + ) + } + + @Test + fun maxAge_treatsNegativeClockDeltaAsFresh() { + assertSame( + FetchPlan.Skip, + plan( + hasResidentValue = true, + meta = meta(writtenAtEpochMillis = 601_000L), + epochStale = false, + freshness = Freshness.MaxAge(5.minutes), + nowEpochMillis = 600_000L, + ), + ) + assertSame( + FetchPlan.Skip, + plan( + hasResidentValue = true, + meta = meta(writtenAtEpochMillis = Long.MAX_VALUE), + epochStale = false, + freshness = Freshness.MaxAge(Duration.ZERO), + nowEpochMillis = Long.MIN_VALUE, + ), + ) + } + + @Test + fun conditionalPlanned_whenEtagPresentAndFetchDue() { + val result = + plan( + hasResidentValue = true, + meta = meta(writtenAtEpochMillis = 0L, etag = "e1"), + epochStale = true, + freshness = Freshness.CachedOrFetch, + ) + + val conditional = assertIs(result) + assertEquals("e1", conditional.etag) + assertEquals(true, conditional.servesResidentWhileFetching) + } + + @Test + fun plainFetchPlanned_whenNoEtag() { + assertFetch( + plan( + hasResidentValue = true, + meta = meta(writtenAtEpochMillis = 0L), + epochStale = true, + freshness = Freshness.CachedOrFetch, + ), + servesResident = true, + ) + } + + @Test + fun maxAge_overBoundWithEtag_plansConditionalWithheld() { + val result = + plan( + hasResidentValue = true, + meta = meta(writtenAtEpochMillis = 0L, etag = "e1"), + epochStale = false, + freshness = Freshness.MaxAge(5.minutes), + nowEpochMillis = 600_000L, + ) + + val conditional = assertIs(result) + assertEquals("e1", conditional.etag) + assertEquals(false, conditional.servesResidentWhileFetching) + } + + @Test + fun durablyStale_forcesFetchUnderCachedOrFetchAndMaxAgeWithinBound() { + val status = + KeyStatus( + meta = meta(writtenAtEpochMillis = 599_000L, etag = "e1"), + lastSuccessSequence = 1L, + lastFailureAtEpochMillis = null, + consecutiveFailures = 0, + durablyStale = true, + ) + + val cached = + assertIs( + plan( + hasResidentValue = true, + meta = status.meta, + epochStale = false, + freshness = Freshness.CachedOrFetch, + nowEpochMillis = 600_000L, + status = status, + ), + ) + assertEquals(true, cached.servesResidentWhileFetching) + + val maxAge = + assertIs( + plan( + hasResidentValue = true, + meta = status.meta, + epochStale = false, + freshness = Freshness.MaxAge(5.minutes), + nowEpochMillis = 600_000L, + status = status, + ), + ) + assertEquals(false, maxAge.servesResidentWhileFetching) + } + + @Test + fun mustBeFresh_withEtagAndResident_plansConditionalWithheld() { + val result = + plan( + hasResidentValue = true, + meta = meta(writtenAtEpochMillis = 0L, etag = "e1"), + epochStale = false, + freshness = Freshness.MustBeFresh, + ) + + val conditional = assertIs(result) + assertEquals("e1", conditional.etag) + assertEquals(false, conditional.servesResidentWhileFetching) + } + + private fun plan( + hasResidentValue: Boolean, + meta: StoreMeta?, + epochStale: Boolean, + freshness: Freshness, + nowEpochMillis: Long = 0L, + status: KeyStatus? = null, + ): FetchPlan = + DefaultFreshnessValidator.plan( + FreshnessContext( + hasResidentValue = hasResidentValue, + meta = meta, + epochStale = epochStale, + freshness = freshness, + nowEpochMillis = nowEpochMillis, + status = status, + ), + ) + + private fun meta( + writtenAtEpochMillis: Long, + etag: String? = null, + ): EngineStoreMeta = + EngineStoreMeta( + writtenAtEpochMillis = writtenAtEpochMillis, + etag = etag, + ) + + private fun assertFetch( + plan: FetchPlan, + servesResident: Boolean, + ) { + assertEquals(servesResident, assertIs(plan).servesResidentWhileFetching) + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/InMemoryBookkeeperKitTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/InMemoryBookkeeperKitTest.kt new file mode 100644 index 000000000..d92f801c7 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/InMemoryBookkeeperKitTest.kt @@ -0,0 +1,11 @@ +package org.mobilenativefoundation.store6.core.internal + +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.testing.BookkeeperContractKit + +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +class InMemoryBookkeeperKitTest : BookkeeperContractKit() { + override fun createBookkeeper(): Bookkeeper = InMemoryBookkeeper() +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/InMemoryBookkeeperTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/InMemoryBookkeeperTest.kt new file mode 100644 index 000000000..c79c74ac6 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/InMemoryBookkeeperTest.kt @@ -0,0 +1,402 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.NamespacedTestKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith +import kotlin.test.assertNotSame +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class InMemoryBookkeeperTest { + private val keyA = NamespacedTestKey(ns = "test", id = "a") + private val keyB = NamespacedTestKey(ns = "test", id = "b") + private val keyOtherNamespace = NamespacedTestKey(ns = "other", id = "a") + + @Test + fun successSequence_isMonotoneAcrossKeys() = runTest { + val bookkeeper = InMemoryBookkeeper() + + bookkeeper.recordSuccess(keyA, EngineStoreMeta(writtenAtEpochMillis = 1L, etag = "a")) + bookkeeper.recordSuccess(keyB, EngineStoreMeta(writtenAtEpochMillis = 2L, etag = "b")) + + val first = requireNotNull(bookkeeper.status(keyA)) + val second = requireNotNull(bookkeeper.status(keyB)) + assertEquals(1L, first.lastSuccessSequence) + assertEquals(2L, second.lastSuccessSequence) + } + + @Test + fun failures_preserveSuccessAndSubsequentSuccessResetsFailures() = runTest { + val bookkeeper = InMemoryBookkeeper() + val e1 = EngineStoreMeta(writtenAtEpochMillis = 1L, etag = "e1") + bookkeeper.recordSuccess(keyA, e1) + + bookkeeper.recordFailure(keyA, atEpochMillis = 2L) + bookkeeper.recordFailure(keyA, atEpochMillis = 3L) + + val failed = requireNotNull(bookkeeper.status(keyA)) + assertSame(e1, failed.meta) + assertEquals(1L, failed.lastSuccessSequence) + assertEquals(3L, failed.lastFailureAtEpochMillis) + assertEquals(2, failed.consecutiveFailures) + + val e2 = EngineStoreMeta(writtenAtEpochMillis = 4L, etag = "e2") + bookkeeper.recordSuccess(keyA, e2) + + val recovered = requireNotNull(bookkeeper.status(keyA)) + assertSame(e2, recovered.meta) + assertEquals(2L, recovered.lastSuccessSequence) + assertNull(recovered.lastFailureAtEpochMillis) + assertEquals(0, recovered.consecutiveFailures) + } + + @Test + fun forget_dropsRecord() = runTest { + val bookkeeper = InMemoryBookkeeper() + bookkeeper.recordSuccess(keyA, EngineStoreMeta(writtenAtEpochMillis = 1L, etag = null)) + + bookkeeper.forget(keyA) + + assertNull(bookkeeper.status(keyA)) + } + + @Test + fun markStale_isScopedToOneKey() = runTest { + val bookkeeper = InMemoryBookkeeper() + + assertNull(bookkeeper.status(keyA)) + bookkeeper.markStale(keyA) + + val marked = requireNotNull(bookkeeper.status(keyA)) + assertNull(marked.meta) + assertNull(marked.lastSuccessSequence) + assertTrue(marked.durablyStale) + assertNull(bookkeeper.status(keyB)) + } + + @Test + fun namespaceAndGlobalWatermarks_coverNeverSeenKeys() = runTest { + val bookkeeper = InMemoryBookkeeper() + + assertNull(bookkeeper.status(keyA)) + assertNull(bookkeeper.status(keyOtherNamespace)) + + bookkeeper.advanceStaleWatermark(keyA.namespace) + + assertTrue(requireNotNull(bookkeeper.status(keyA)).durablyStale) + assertTrue(requireNotNull(bookkeeper.status(keyB)).durablyStale) + assertNull(bookkeeper.status(keyOtherNamespace)) + + bookkeeper.advanceGlobalStaleWatermark() + + assertTrue(requireNotNull(bookkeeper.status(keyOtherNamespace)).durablyStale) + } + + @Test + fun failureOnlyRecord_usesZeroSuccessFloor() = runTest { + val bookkeeper = InMemoryBookkeeper() + bookkeeper.recordFailure(keyA, atEpochMillis = 10L) + + val failed = requireNotNull(bookkeeper.status(keyA)) + assertNull(failed.lastSuccessSequence) + assertFalse(failed.durablyStale) + + bookkeeper.markStale(keyA) + + val marked = requireNotNull(bookkeeper.status(keyA)) + assertNull(marked.lastSuccessSequence) + assertTrue(marked.durablyStale) + } + + @Test + fun laterSuccess_outranksKeyNamespaceAndGlobalMarks() = runTest { + val bookkeeper = InMemoryBookkeeper() + bookkeeper.recordFailure(keyA, atEpochMillis = 10L) + bookkeeper.markStale(keyA) + bookkeeper.advanceStaleWatermark(keyA.namespace) + bookkeeper.advanceGlobalStaleWatermark() + val meta = EngineStoreMeta(writtenAtEpochMillis = 20L, etag = "fresh") + + bookkeeper.recordSuccess(keyA, meta) + + val status = requireNotNull(bookkeeper.status(keyA)) + assertSame(meta, status.meta) + assertEquals(4L, status.lastSuccessSequence) + assertNull(status.lastFailureAtEpochMillis) + assertEquals(0, status.consecutiveFailures) + assertFalse(status.durablyStale) + } + + @Test + fun forgetNamespace_dropsMatchingRecordsButPreservesWatermark() = runTest { + val bookkeeper = InMemoryBookkeeper() + val matchingMeta = EngineStoreMeta(writtenAtEpochMillis = 1L, etag = "matching") + val otherMeta = EngineStoreMeta(writtenAtEpochMillis = 2L, etag = "other") + bookkeeper.recordSuccess(keyA, matchingMeta) + bookkeeper.recordSuccess(keyOtherNamespace, otherMeta) + bookkeeper.advanceStaleWatermark(keyA.namespace) + + bookkeeper.forgetNamespace(keyA.namespace) + + val forgotten = requireNotNull(bookkeeper.status(keyA)) + assertNull(forgotten.meta) + assertNull(forgotten.lastSuccessSequence) + assertTrue(forgotten.durablyStale) + assertTrue(requireNotNull(bookkeeper.status(keyB)).durablyStale) + assertSame(otherMeta, requireNotNull(bookkeeper.status(keyOtherNamespace)).meta) + + bookkeeper.recordSuccess(keyA, matchingMeta) + + val restored = requireNotNull(bookkeeper.status(keyA)) + assertEquals(4L, restored.lastSuccessSequence) + assertFalse(restored.durablyStale) + } + + @Test + fun forgetAll_dropsRecordsButPreservesAllWatermarks() = runTest { + val bookkeeper = InMemoryBookkeeper() + val matchingMeta = EngineStoreMeta(writtenAtEpochMillis = 1L, etag = "matching") + val otherMeta = EngineStoreMeta(writtenAtEpochMillis = 2L, etag = "other") + val neverSeen = NamespacedTestKey(ns = "never-seen", id = "key") + bookkeeper.recordSuccess(keyA, matchingMeta) + bookkeeper.recordSuccess(keyOtherNamespace, otherMeta) + bookkeeper.advanceStaleWatermark(keyA.namespace) + bookkeeper.advanceGlobalStaleWatermark() + + bookkeeper.forgetAll() + + val matching = requireNotNull(bookkeeper.status(keyA)) + val other = requireNotNull(bookkeeper.status(keyOtherNamespace)) + assertNull(matching.meta) + assertNull(other.meta) + assertTrue(matching.durablyStale) + assertTrue(other.durablyStale) + assertTrue(requireNotNull(bookkeeper.status(neverSeen)).durablyStale) + + bookkeeper.recordSuccess(keyOtherNamespace, otherMeta) + + val restored = requireNotNull(bookkeeper.status(keyOtherNamespace)) + assertEquals(5L, restored.lastSuccessSequence) + assertFalse(restored.durablyStale) + } + + @Test + fun markStale_gateFailurePublishesNeitherRecordNorSequence() = runTest { + var gateFailure: Throwable? = null + val bookkeeper = + InMemoryBookkeeper( + beforeMaintenancePublishTestGate = { gateFailure?.let { throw it } }, + ) + val meta = EngineStoreMeta(writtenAtEpochMillis = 1L, etag = "a") + bookkeeper.recordSuccess(keyA, meta) + val before = requireNotNull(bookkeeper.status(keyA)) + gateFailure = IllegalStateException("mark publish failed") + + val failure = assertFailsWith { bookkeeper.markStale(keyA) } + + assertEquals("mark publish failed", failure.message) + assertSame(before, bookkeeper.status(keyA)) + assertFalse(before.durablyStale) + + gateFailure = null + bookkeeper.markStale(keyA) + bookkeeper.recordSuccess( + keyB, + EngineStoreMeta(writtenAtEpochMillis = 2L, etag = "b"), + ) + assertEquals(3L, requireNotNull(bookkeeper.status(keyB)).lastSuccessSequence) + } + + @Test + fun namespaceWatermark_gateFailurePublishesNeitherWatermarkNorSequence() = runTest { + var gateFailure: Throwable? = null + val bookkeeper = + InMemoryBookkeeper( + beforeMaintenancePublishTestGate = { gateFailure?.let { throw it } }, + ) + bookkeeper.recordSuccess( + keyA, + EngineStoreMeta(writtenAtEpochMillis = 1L, etag = "a"), + ) + val before = requireNotNull(bookkeeper.status(keyA)) + gateFailure = IllegalStateException("namespace watermark publish failed") + + val failure = + assertFailsWith { + bookkeeper.advanceStaleWatermark(keyA.namespace) + } + + assertEquals("namespace watermark publish failed", failure.message) + assertSame(before, bookkeeper.status(keyA)) + assertFalse(before.durablyStale) + + gateFailure = null + bookkeeper.recordSuccess( + keyB, + EngineStoreMeta(writtenAtEpochMillis = 2L, etag = "b"), + ) + assertEquals(2L, requireNotNull(bookkeeper.status(keyB)).lastSuccessSequence) + } + + @Test + fun globalWatermark_gateFailurePublishesNeitherWatermarkNorSequence() = runTest { + var gateFailure: Throwable? = null + val bookkeeper = + InMemoryBookkeeper( + beforeMaintenancePublishTestGate = { gateFailure?.let { throw it } }, + ) + bookkeeper.recordSuccess( + keyA, + EngineStoreMeta(writtenAtEpochMillis = 1L, etag = "a"), + ) + val before = requireNotNull(bookkeeper.status(keyA)) + gateFailure = IllegalStateException("global watermark publish failed") + + val failure = + assertFailsWith { + bookkeeper.advanceGlobalStaleWatermark() + } + + assertEquals("global watermark publish failed", failure.message) + assertSame(before, bookkeeper.status(keyA)) + assertNull(bookkeeper.status(keyOtherNamespace)) + + gateFailure = null + bookkeeper.recordSuccess( + keyB, + EngineStoreMeta(writtenAtEpochMillis = 2L, etag = "b"), + ) + assertEquals(2L, requireNotNull(bookkeeper.status(keyB)).lastSuccessSequence) + } + + @Test + fun forgetNamespace_gateFailureLeavesEveryRecordPublished() = runTest { + var gateFailure: Throwable? = null + val bookkeeper = + InMemoryBookkeeper( + beforeMaintenancePublishTestGate = { gateFailure?.let { throw it } }, + ) + bookkeeper.recordSuccess( + keyA, + EngineStoreMeta(writtenAtEpochMillis = 1L, etag = "a"), + ) + bookkeeper.recordSuccess( + keyOtherNamespace, + EngineStoreMeta(writtenAtEpochMillis = 2L, etag = "other"), + ) + val matchingBefore = requireNotNull(bookkeeper.status(keyA)) + val otherBefore = requireNotNull(bookkeeper.status(keyOtherNamespace)) + gateFailure = IllegalStateException("forget publish failed") + + val failure = + assertFailsWith { + bookkeeper.forgetNamespace(keyA.namespace) + } + + assertEquals("forget publish failed", failure.message) + assertSame(matchingBefore, bookkeeper.status(keyA)) + assertSame(otherBefore, bookkeeper.status(keyOtherNamespace)) + + gateFailure = null + bookkeeper.recordSuccess( + keyB, + EngineStoreMeta(writtenAtEpochMillis = 3L, etag = "b"), + ) + assertEquals(3L, requireNotNull(bookkeeper.status(keyB)).lastSuccessSequence) + } + + @Test + fun successThenKeyMark_isDurablyStale() = runTest { + val bookkeeper = InMemoryBookkeeper() + bookkeeper.recordSuccess( + keyA, + EngineStoreMeta(writtenAtEpochMillis = 1L, etag = null), + ) + + bookkeeper.markStale(keyA) + + assertTrue(requireNotNull(bookkeeper.status(keyA)).durablyStale) + } + + @Test + fun successThenNamespaceWatermark_isDurablyStaleAndChangesIdentityOnce() = runTest { + val bookkeeper = InMemoryBookkeeper() + bookkeeper.recordSuccess( + keyA, + EngineStoreMeta(writtenAtEpochMillis = 1L, etag = null), + ) + val fresh = requireNotNull(bookkeeper.status(keyA)) + + bookkeeper.advanceStaleWatermark(keyOtherNamespace.namespace) + assertSame(fresh, bookkeeper.status(keyA)) + + bookkeeper.advanceStaleWatermark(keyA.namespace) + val stale = requireNotNull(bookkeeper.status(keyA)) + assertNotSame(fresh, stale) + assertTrue(stale.durablyStale) + + bookkeeper.advanceStaleWatermark(keyA.namespace) + assertSame(stale, bookkeeper.status(keyA)) + } + + @Test + fun successThenGlobalWatermark_isDurablyStale() = runTest { + val bookkeeper = InMemoryBookkeeper() + bookkeeper.recordSuccess( + keyA, + EngineStoreMeta(writtenAtEpochMillis = 1L, etag = null), + ) + + bookkeeper.advanceGlobalStaleWatermark() + + assertTrue(requireNotNull(bookkeeper.status(keyA)).durablyStale) + } + + @Test + fun watermarkOnlyStatus_isReusedForNeverSeenAndForgottenKeys() = runTest { + val bookkeeper = InMemoryBookkeeper() + bookkeeper.advanceGlobalStaleWatermark() + val neverSeen = requireNotNull(bookkeeper.status(keyA)) + + assertSame(neverSeen, bookkeeper.status(keyOtherNamespace)) + + bookkeeper.recordSuccess( + keyA, + EngineStoreMeta(writtenAtEpochMillis = 1L, etag = null), + ) + bookkeeper.forgetAll() + + assertSame(neverSeen, bookkeeper.status(keyA)) + } + + @Test + fun sequenceExhaustion_failsBeforeAnyMutation() = runTest { + val bookkeeper = InMemoryBookkeeper(initialSequence = Long.MAX_VALUE - 1L) + bookkeeper.recordSuccess( + keyA, + EngineStoreMeta(writtenAtEpochMillis = 1L, etag = null), + ) + val before = requireNotNull(bookkeeper.status(keyA)) + assertEquals(Long.MAX_VALUE, before.lastSuccessSequence) + + val markFailure = assertFailsWith { bookkeeper.markStale(keyA) } + assertEquals("Bookkeeper sequence exhausted", markFailure.message) + assertSame(before, bookkeeper.status(keyA)) + + val successFailure = + assertFailsWith { + bookkeeper.recordSuccess( + keyB, + EngineStoreMeta(writtenAtEpochMillis = 2L, etag = null), + ) + } + assertEquals("Bookkeeper sequence exhausted", successFailure.message) + assertNull(bookkeeper.status(keyB)) + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/InMemorySourceOfTruthKitTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/InMemorySourceOfTruthKitTest.kt new file mode 100644 index 000000000..53cf39b2e --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/InMemorySourceOfTruthKitTest.kt @@ -0,0 +1,108 @@ +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.NamespacedTestKey +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import org.mobilenativefoundation.store6.testing.SourceOfTruthContractKit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +class InMemorySourceOfTruthKitTest : SourceOfTruthContractKit() { + override fun createSourceOfTruth(): SourceOfTruth = + InMemorySourceOfTruth() + + override val keyA: NamespacedTestKey = NamespacedTestKey(ns = "primary", id = "a") + override val keyB: NamespacedTestKey = NamespacedTestKey(ns = "primary", id = "b") + override val keyOtherNamespace: NamespacedTestKey = + NamespacedTestKey(ns = "other", id = "a") + + override fun value(index: Int): String = "value-$index" +} + +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +class SharedFlowSourceOfTruthKitTest : SourceOfTruthContractKit() { + override fun createSourceOfTruth(): SourceOfTruth = + SharedFlowSourceOfTruth() + + override val keyA: NamespacedTestKey = NamespacedTestKey(ns = "primary", id = "a") + override val keyB: NamespacedTestKey = NamespacedTestKey(ns = "primary", id = "b") + override val keyOtherNamespace: NamespacedTestKey = + NamespacedTestKey(ns = "other", id = "a") + + override fun value(index: Int): String = "value-$index" +} + +@OptIn( + DelicateStoreApi::class, + ExperimentalStoreApi::class, + kotlinx.coroutines.ExperimentalCoroutinesApi::class, +) +class SharedFlowSourceOfTruthLinearizationTest { + @Test + fun namespaceDeleteSerializesWriteAfterBulkReturn(): TestResult = runTest { + val keyA = NamespacedTestKey(ns = "primary", id = "a") + val keyB = NamespacedTestKey(ns = "primary", id = "b") + val keyCreatedDuringBulk = NamespacedTestKey(ns = "primary", id = "new") + val bulkEmissionStarted = CompletableDeferred() + val releaseBulkDelete = CompletableDeferred() + val sourceOfTruth = + SharedFlowSourceOfTruth { + bulkEmissionStarted.complete(Unit) + releaseBulkDelete.await() + } + sourceOfTruth.write(keyA, "a") + sourceOfTruth.write(keyB, "b") + + val bulkDelete = async { sourceOfTruth.deleteNamespace(keyA.namespace) } + var laterWrite: kotlinx.coroutines.Deferred? = null + var writeInterleaved = false + try { + bulkEmissionStarted.await() + laterWrite = async { sourceOfTruth.write(keyA, "later") } + runCurrent() + writeInterleaved = laterWrite.isCompleted + assertNull(sourceOfTruth.reader(keyCreatedDuringBulk).first()) + } finally { + releaseBulkDelete.complete(Unit) + } + + bulkDelete.await() + checkNotNull(laterWrite).await() + + assertFalse(writeInterleaved) + assertEquals("later", sourceOfTruth.reader(keyA).first()) + } + + @Test + fun bulkGateFailureLeavesEveryRowUnchanged(): TestResult = runTest { + val keyA = NamespacedTestKey(ns = "primary", id = "a") + val keyB = NamespacedTestKey(ns = "primary", id = "b") + val failure = IllegalStateException("bulk gate failed") + val sourceOfTruth = + SharedFlowSourceOfTruth { + throw failure + } + sourceOfTruth.write(keyA, "a") + sourceOfTruth.write(keyB, "b") + + val thrown = + assertFailsWith { + sourceOfTruth.deleteAll() + } + + assertEquals(failure.message, thrown.message) + assertEquals("a", sourceOfTruth.reader(keyA).first()) + assertEquals("b", sourceOfTruth.reader(keyB).first()) + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/InMemorySourceOfTruthTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/InMemorySourceOfTruthTest.kt new file mode 100644 index 000000000..564b33bec --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/InMemorySourceOfTruthTest.kt @@ -0,0 +1,50 @@ +package org.mobilenativefoundation.store6.core.internal + +import app.cash.turbine.test +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.NamespacedTestKey +import org.mobilenativefoundation.store6.core.TestKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +@OptIn(ExperimentalStoreApi::class) +class InMemorySourceOfTruthTest { + @Test + fun reader_emitsInitialRowEqualWritesAndDeleteWithoutCompleting() = runTest { + val sourceOfTruth = InMemorySourceOfTruth() + val key = TestKey("key") + + sourceOfTruth.reader(key).test { + assertNull(awaitItem()) + + sourceOfTruth.write(key, "value") + assertEquals("value", awaitItem()) + + sourceOfTruth.write(TestKey("key"), "value") + assertEquals("value", awaitItem()) + + sourceOfTruth.delete(TestKey("key")) + assertNull(awaitItem()) + + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun newReader_startsWithCurrentCanonicalRowAndKeepsNamespacesIsolated() = runTest { + val sourceOfTruth = InMemorySourceOfTruth() + sourceOfTruth.write(NamespacedTestKey(ns = "first", id = "key"), "value") + + sourceOfTruth.reader(NamespacedTestKey(ns = "first", id = "key")).test { + assertEquals("value", awaitItem()) + cancelAndIgnoreRemainingEvents() + } + sourceOfTruth.reader(NamespacedTestKey(ns = "second", id = "key")).test { + assertNull(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/KeyEnginePlanningTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/KeyEnginePlanningTest.kt new file mode 100644 index 000000000..dd7ab1c6d --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/KeyEnginePlanningTest.kt @@ -0,0 +1,5052 @@ +package org.mobilenativefoundation.store6.core.internal + +import app.cash.turbine.test +import app.cash.turbine.testIn +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.FakeWallClock +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreMeta +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.TestKey +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.core.seam.FetchPlan +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import org.mobilenativefoundation.store6.core.seam.FreshnessContext +import org.mobilenativefoundation.store6.core.seam.FreshnessValidator +import org.mobilenativefoundation.store6.core.seam.KeyStatus +import org.mobilenativefoundation.store6.core.seam.Overlay +import org.mobilenativefoundation.store6.core.SingleRowTestSourceOfTruth +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertNotNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlin.test.fail +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes + +private val COLD_OBSERVATION_HOLD = 60.milliseconds +private val WRITER_CURRENT_DELIVERY_GAP = 1.milliseconds + +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class, ExperimentalCoroutinesApi::class) +class KeyEnginePlanningTest { + @Test + fun hydration_reusesWrittenAtButStripsOldEtagAfterExternalReplacement() = runTest { + val key = TestKey("external-replacement-meta") + val durableSot = InMemorySourceOfTruth() + val durableBookkeeper = InMemoryBookkeeper() + val clock = FakeWallClock(now = 123L) + val first = + KeyEngine( + key, + KeyId.from(key), + ResultFetcher { FetcherResult.Success("engine", etag = "old-etag") }, + durableSot, + durableBookkeeper, + DefaultFreshnessValidator, + clock, + backgroundScope, + ) + assertEquals("engine", first.get(Freshness.MustBeFresh)) + durableSot.write(key, "external") + + var observed: FreshnessContext? = null + val capturingValidator = + object : FreshnessValidator { + override fun plan(context: FreshnessContext): FetchPlan { + observed = context + return FetchPlan.Skip + } + } + val restarted = + KeyEngine( + key, + KeyId.from(key), + ResultFetcher { error("LocalOnly hydration must not fetch") }, + durableSot, + durableBookkeeper, + capturingValidator, + clock, + backgroundScope, + ) + + assertEquals("external", restarted.get(Freshness.LocalOnly)) + assertEquals(123L, observed?.meta?.writtenAtEpochMillis) + assertNull(observed?.meta?.etag, "external rows must never inherit the old engine ETag") + } + + @Test + fun hydration_durablyStaleRowIsEpochStaleAndStatusVisibleToPlanning() = runTest { + val key = TestKey("durably-stale-hydration") + val durableSot = InMemorySourceOfTruth() + val durableBookkeeper = InMemoryBookkeeper() + val first = + KeyEngine( + key, + KeyId.from(key), + ResultFetcher { FetcherResult.Success("v1", etag = "e1") }, + durableSot, + durableBookkeeper, + DefaultFreshnessValidator, + FakeWallClock(now = 10L), + backgroundScope, + ) + assertEquals("v1", first.get(Freshness.MustBeFresh)) + durableBookkeeper.markStale(key) + + var observed: FreshnessContext? = null + val restarted = + KeyEngine( + key, + KeyId.from(key), + ResultFetcher { error("LocalOnly hydration must not fetch") }, + durableSot, + durableBookkeeper, + object : FreshnessValidator { + override fun plan(context: FreshnessContext): FetchPlan { + observed = context + return FetchPlan.Skip + } + }, + FakeWallClock(now = 20L), + backgroundScope, + ) + + restarted.stream(Freshness.LocalOnly).test { + val data = assertIs>(awaitItem()) + assertEquals("v1", data.value) + assertTrue(data.isStale, "durably stale hydration must be epoch-stale") + cancelAndIgnoreRemainingEvents() + } + assertEquals(true, observed?.status?.durablyStale) + } + + @Test + fun exactOwnerNotModified_emitsRevalidatedAgeAndNoThirdFetchWhileSuccessIsGated() = runTest { + val key = TestKey("owner-revalidated") + val keyId = KeyId.from(key) + val durableBookkeeper = PreDelegateSuccessGateBookkeeper() + val clock = FakeWallClock(now = 100L) + val secondFetchEntered = CompletableDeferred() + val releaseSecondFetch = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key, + keyId, + ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> { + secondFetchEntered.complete(Unit) + releaseSecondFetch.await() + FetcherResult.NotModified("e1") + } + else -> error("unexpected fetch call $calls") + } + }, + InMemorySourceOfTruth(), + durableBookkeeper, + DefaultFreshnessValidator, + clock, + backgroundScope, + ) + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + engine.invalidate() + clock.now = 150L + durableBookkeeper.gateNextSuccess() + + engine.stream(Freshness.CachedOrFetch).test { + try { + val stale = assertIs>(awaitItem()) + assertTrue(stale.isStale) + assertTrue(stale.refreshing) + withTimeout(1_000) { secondFetchEntered.await() } + val ticket = assertIs(engine.state.value.fetch).ticket + val baselineRevision = assertNotNull(ticket.residenceRevisionAtLaunch) + + releaseSecondFetch.complete(Unit) + withTimeout(1_000) { durableBookkeeper.successEntered.await() } + val early = assertIs(ticket.disposition.value) + assertEquals(true, durableBookkeeper.status(key)?.durablyStale) + assertEquals(2, calls, "pre-success stale status must not manufacture a third fetch") + + durableBookkeeper.releaseSuccess.complete(Unit) + val outcome = + assertIs(withTimeout(1_000) { ticket.outcome.await() }) + assertEquals(baselineRevision + 1L, outcome.residenceRevision) + assertSame(early.envelope, outcome.envelope) + assertSame( + outcome.envelope, + assertIs(ticket.disposition.value).envelope, + ) + + var publicResult = awaitItem() + while (publicResult is StoreResult.Data && publicResult.isStale) { + publicResult = awaitItem() + } + val revalidated = assertIs(publicResult) + assertEquals(50L, revalidated.age.inWholeMilliseconds) + cancelAndIgnoreRemainingEvents() + } finally { + releaseSecondFetch.complete(Unit) + durableBookkeeper.releaseSuccess.complete(Unit) + } + } + assertEquals("v1", engine.get(Freshness.CachedOrFetch)) + assertEquals(2, calls) + } + + @Test + fun completedExactOwnerNotModified_rejectsStaleStatusSnapshotWithoutThirdFetch() = runTest { + val key = TestKey("owner-revalidated-stale-status") + val bookkeeper = PostDelegateStatusGateBookkeeper() + val secondFetchEntered = CompletableDeferred() + val releaseSecondFetch = CompletableDeferred() + val thirdFetchEntered = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = + ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> { + secondFetchEntered.complete(Unit) + releaseSecondFetch.await() + FetcherResult.NotModified("e1") + } + 3 -> { + thirdFetchEntered.complete(Unit) + FetcherResult.Success("v2", etag = "e2") + } + else -> error("unexpected fetch call $calls") + } + }, + sot = InMemorySourceOfTruth(), + bookkeeper = bookkeeper, + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 100L), + engineScope = backgroundScope, + ) + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + engine.invalidate() + val owner = async { engine.get(Freshness.MustBeFresh) } + secondFetchEntered.await() + val ticket = assertIs(engine.state.value.fetch).ticket + + bookkeeper.gateStatusCall(number = 2) + val staleWaiter = async { engine.get(Freshness.CachedOrFetch) } + bookkeeper.statusEntered.await() + + releaseSecondFetch.complete(Unit) + assertIs(ticket.outcome.await()) + bookkeeper.releaseStatus.complete(Unit) + + assertEquals("v1", owner.await()) + assertEquals("v1", staleWaiter.await()) + testScheduler.runCurrent() + assertFalse(thirdFetchEntered.isCompleted) + assertEquals(2, calls) + assertIs(engine.state.value.fetch) + assertEquals("v2", engine.get(Freshness.MustBeFresh)) + assertTrue(thirdFetchEntered.isCompleted) + assertEquals(3, calls, "a later MustBeFresh demand must not reuse the old 304 owner") + } + + // A 304 that launched against a null residence baseline but finds residence present at + // commit is an obsolete launch snapshot, not an adapter-contract violation. It must + // classify ObsoleteRevalidation and self-heal by replanning once. + @Test + fun coldBaselineNotModified_hydratedBeforeCommit_classifiesObsoleteAndReplans() = runTest { + val key = TestKey("cold-304-hydrated") + val sourceOfTruth = InMemorySourceOfTruth() + val firstFetchEntered = CompletableDeferred() + val releaseFirstFetch = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> { + firstFetchEntered.complete(Unit) + releaseFirstFetch.await() + FetcherResult.NotModified("e1") + } + 2 -> FetcherResult.NotModified("e1") + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + ) + + engine.stream(Freshness.CachedOrFetch).test { + assertIs(awaitItem()) + firstFetchEntered.await() + val ticket = assertIs(engine.state.value.fetch).ticket + assertNull(ticket.residenceRevisionAtLaunch) + + sourceOfTruth.write(key, "external") + assertEquals("external", assertIs>(awaitItem()).value) + + releaseFirstFetch.complete(Unit) + assertIs(ticket.outcome.await()) + + while (true) { + when (val item = awaitItem()) { + is StoreResult.Data -> assertEquals("external", item.value) + is StoreResult.Revalidated -> break + else -> fail("unexpected lifecycle item ${item::class.simpleName}") + } + } + testScheduler.runCurrent() + assertEquals(2, calls, "the cold-baseline 304 self-heal replans exactly once") + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun trulyColdNotModified_staysTypedMissingAdapterContractFailure() = runTest { + val key = TestKey("cold-304-empty") + val firstFetchEntered = CompletableDeferred() + val releaseFirstFetch = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> { + firstFetchEntered.complete(Unit) + releaseFirstFetch.await() + FetcherResult.NotModified("e1") + } + else -> error("unexpected fetch call $calls") + } + }, + sot = InMemorySourceOfTruth(), + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + ) + + engine.stream(Freshness.CachedOrFetch).test { + assertIs(awaitItem()) + firstFetchEntered.await() + val ticket = assertIs(engine.state.value.fetch).ticket + assertNull(ticket.residenceRevisionAtLaunch) + + releaseFirstFetch.complete(Unit) + val failed = assertIs(ticket.outcome.await()) + assertIs(failed.exception.error) + + val failure = assertIs(awaitItem()) + assertIs(failure.error) + assertFalse(failure.servedStale) + testScheduler.runCurrent() + assertEquals(1, calls, "a truly cold 304 is terminal and must not replan") + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun exactOwnerNotModified_backwardClockEmitsZeroAge() = runTest { + val key = TestKey("owner-revalidated-backward-clock") + val clock = FakeWallClock(now = 100L) + var calls = 0 + val engine = + KeyEngine( + key, + KeyId.from(key), + ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + else -> FetcherResult.NotModified("e1") + } + }, + InMemorySourceOfTruth(), + InMemoryBookkeeper(), + DefaultFreshnessValidator, + clock, + backgroundScope, + ) + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + clock.now = 50L + + engine.stream(Freshness.MustBeFresh).test { + assertIs(awaitItem()) + assertEquals(Duration.ZERO, assertIs(awaitItem()).age) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun exactOwnerNotModified_nullPreRefreshMetadataEmitsZeroAge() = runTest { + val key = TestKey("owner-revalidated-null-meta") + val sourceOfTruth = InMemorySourceOfTruth() + sourceOfTruth.write(key, "seed") + val engine = + KeyEngine( + key, + KeyId.from(key), + ResultFetcher { FetcherResult.NotModified("e1") }, + sourceOfTruth, + InMemoryBookkeeper(), + DefaultFreshnessValidator, + FakeWallClock(now = 50L), + backgroundScope, + ) + assertEquals("seed", engine.get(Freshness.LocalOnly)) + + engine.stream(Freshness.MustBeFresh).test { + assertIs(awaitItem()) + assertEquals(Duration.ZERO, assertIs(awaitItem()).age) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun exactOwnerNotModified_overflowAgeSaturatesToLongMaxMillis() = runTest { + val key = TestKey("owner-revalidated-overflow-age") + val clock = FakeWallClock(now = Long.MIN_VALUE) + var calls = 0 + val engine = + KeyEngine( + key, + KeyId.from(key), + ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + else -> FetcherResult.NotModified("e1") + } + }, + InMemorySourceOfTruth(), + InMemoryBookkeeper(), + DefaultFreshnessValidator, + clock, + backgroundScope, + ) + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + clock.now = Long.MAX_VALUE + + engine.stream(Freshness.MustBeFresh).test { + assertIs(awaitItem()) + val age = assertIs(awaitItem()).age + assertEquals(Long.MAX_VALUE, age.inWholeMilliseconds) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun staleEpochAdvancedBeforeSubscription_isReplayedAfterPlanningEpoch() = runTest { + val states = MutableStateFlow(KeyState.Initial) + val planningEpoch = states.value.staleEpoch + states.value = states.value.copy(staleEpoch = planningEpoch + 1L) + + assertEquals( + planningEpoch + 1L, + states.staleEpochsAfter(planningEpoch).first(), + ) + } + + @Test + fun mustBeFreshInvalidationDuringSuccessfulInitialFetch_isSatisfiedByCommit() = runTest { + val key = TestKey("1") + var calls = 0 + val firstStarted = CompletableDeferred() + val firstGate = CompletableDeferred() + val secondStarted = CompletableDeferred() + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> { + firstStarted.complete(Unit) + firstGate.await() + FetcherResult.Success("v1") + } + + 2 -> { + secondStarted.complete(Unit) + FetcherResult.Success("v2") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = InMemorySourceOfTruth(), + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + ) + + engine.stream(Freshness.MustBeFresh).test { + assertIs(awaitItem()) + firstStarted.await() + engine.invalidate() + firstGate.complete(Unit) + + assertEquals("v1", assertIs>(awaitItem()).value) + testScheduler.runCurrent() + assertEquals(1, calls) + assertFalse(secondStarted.isCompleted) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun mustBeFreshFastInitialCommit_doesNotMintAReplacementTicket() = runTest { + val key = TestKey("fast") + val sourceOfTruth = WriteStartedSourceOfTruth() + val gate = InitialDeliveryGate().also { it.arm() } + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + calls += 1 + fetchStarted.complete(Unit) + releaseFetch.await() + FetcherResult.Success("v$calls") + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeInitialDeliveryTestGate = gate::awaitIfArmed, + ) + + app.cash.turbine.turbineScope { + val collector = engine.stream(Freshness.MustBeFresh).testIn(backgroundScope) + gate.entered.await() + fetchStarted.await() + val ticket = assertIs(engine.state.value.fetch).ticket + releaseFetch.complete(Unit) + assertIs(ticket.outcome.await()) + gate.release() + + assertIs(collector.awaitItem()) + val data = assertIs>(collector.awaitItem()) + assertEquals("v1", data.value) + assertFalse(data.refreshing) + testScheduler.runCurrent() + assertEquals(1, calls) + collector.expectNoEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun cachedFastInitialCommit_afterInitialSnapshot_emitsLoadingBeforeData() = runTest { + val key = TestKey("fast-after-snapshot") + val sourceOfTruth = WriteStartedSourceOfTruth() + val snapshotGate = InitialDeliveryGate().also { it.arm() } + val readerDeliveryGate = InitialDeliveryGate().also { it.arm() } + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + calls += 1 + fetchStarted.complete(Unit) + releaseFetch.await() + FetcherResult.Success("v1") + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + afterInitialPlanningSnapshotTestGate = snapshotGate::awaitIfArmed, + beforeReaderDeliveryTestGate = readerDeliveryGate::awaitIfArmed, + ) + + app.cash.turbine.turbineScope { + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + snapshotGate.entered.await() + fetchStarted.await() + val ticket = assertIs(engine.state.value.fetch).ticket + releaseFetch.complete(Unit) + assertIs(ticket.outcome.await()) + snapshotGate.release() + + assertIs(collector.awaitItem()) + readerDeliveryGate.entered.await() + collector.expectNoEvents() + readerDeliveryGate.release() + val data = assertIs>(collector.awaitItem()) + assertEquals("v1", data.value) + assertEquals(Origin.FETCHER, data.origin) + assertFalse(data.refreshing) + testScheduler.runCurrent() + assertEquals(1, calls) + collector.expectNoEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun cachedFastInitialCommit_waitsForWriterCurrentRow() = runTest { + val key = TestKey("cached-fast") + val sourceOfTruth = WriteStartedSourceOfTruth() + val initialGate = InitialDeliveryGate().also { it.arm() } + val deliveryGate = InitialDeliveryGate().also { it.arm() } + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + calls += 1 + fetchStarted.complete(Unit) + releaseFetch.await() + FetcherResult.Success("v1") + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeInitialDeliveryTestGate = initialGate::awaitIfArmed, + beforeReaderDeliveryTestGate = deliveryGate::awaitIfArmed, + ) + + app.cash.turbine.turbineScope { + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + initialGate.entered.await() + fetchStarted.await() + val ticket = assertIs(engine.state.value.fetch).ticket + releaseFetch.complete(Unit) + assertIs(ticket.outcome.await()) + initialGate.release() + + assertIs(collector.awaitItem()) + deliveryGate.entered.await() + collector.expectNoEvents() + deliveryGate.release() + + val data = assertIs>(collector.awaitItem()) + assertEquals("v1", data.value) + assertEquals(Origin.FETCHER, data.origin) + assertFalse(data.refreshing) + assertEquals(1, calls) + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun completedCommitObservedByInvalidate_reprocessesLatestCausalRowBeforeWatcherNoop() = + runTest { + val key = TestKey("commit-observed-by-invalidate") + val sourceOfTruth = StartupRaceSourceOfTruth() + val bookkeeper = GateSuccessBookkeeper() + val outcomeDeliveryGate = InitialDeliveryGate() + val thirdStarted = CompletableDeferred() + val releaseThird = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1") + 2 -> FetcherResult.Success("v2") + 3 -> { + thirdStarted.complete(Unit) + releaseThird.await() + FetcherResult.Success("v3") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = bookkeeper, + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeTicketOutcomeDeliveryTestGate = outcomeDeliveryGate::awaitIfArmed, + ) + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + app.cash.turbine.turbineScope { + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + assertEquals("v1", assertIs>(collector.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + bookkeeper.gateNextSuccess() + + engine.invalidate() + testScheduler.runCurrent() + assertTrue(bookkeeper.successEntered.isCompleted) + assertEquals("v2", assertIs>(collector.awaitItem()).value) + + outcomeDeliveryGate.arm() + bookkeeper.releaseSuccess.complete(Unit) + testScheduler.runCurrent() + assertTrue(outcomeDeliveryGate.entered.isCompleted) + engine.invalidate() + testScheduler.runCurrent() + + assertTrue(thirdStarted.isCompleted) + collector.expectNoEvents() + outcomeDeliveryGate.release() + releaseThird.complete(Unit) + assertEquals("v3", assertIs>(collector.awaitItem()).value) + assertEquals(3, calls) + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun completedInitialDelete_doesNotLaunchAReplacement() = runTest { + val key = TestKey("deleted-fast") + val gate = InitialDeliveryGate().also { it.arm() } + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + calls += 1 + fetchStarted.complete(Unit) + releaseFetch.await() + FetcherResult.Deleted + }, + sot = InMemorySourceOfTruth(), + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeInitialDeliveryTestGate = gate::awaitIfArmed, + ) + + app.cash.turbine.turbineScope { + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + gate.entered.await() + fetchStarted.await() + val ticket = assertIs(engine.state.value.fetch).ticket + releaseFetch.complete(Unit) + assertIs(ticket.outcome.await()) + gate.release() + + assertIs(collector.awaitItem()) + assertIs(assertIs(collector.awaitItem()).error) + testScheduler.runCurrent() + assertEquals(1, calls) + collector.expectNoEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun pendingDeletedOutcome_retiresMissingBeforeNewerSatisfiedRow() = runTest { + val key = TestKey("pending-deleted-newer-row") + val sourceOfTruth = StartupRaceSourceOfTruth() + val bookkeeper = GateForgetBookkeeper() + val deleteStarted = CompletableDeferred() + val releaseDelete = CompletableDeferred() + val replacementStarted = CompletableDeferred() + val releaseReplacement = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1") + 2 -> { + deleteStarted.complete(Unit) + releaseDelete.await() + FetcherResult.Deleted + } + + 3 -> { + replacementStarted.complete(Unit) + releaseReplacement.await() + FetcherResult.Success("recovered") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = bookkeeper, + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + ) + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + app.cash.turbine.turbineScope { + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + assertEquals("v1", assertIs>(collector.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + bookkeeper.gateNextForget() + engine.invalidate() + deleteStarted.await() + val deletedTicket = assertIs(engine.state.value.fetch).ticket + releaseDelete.complete(Unit) + bookkeeper.forgetEntered.await() + assertIs(deletedTicket.disposition.value) + assertFalse(deletedTicket.outcome.isCompleted) + + sourceOfTruth.publish("external") + testScheduler.runCurrent() + collector.expectNoEvents() + bookkeeper.releaseForget.complete(Unit) + + val delivered = mutableListOf>() + while (delivered.none { it is StoreResult.Data && it.value == "external" }) { + delivered += collector.awaitItem() + } + val missingIndex = + delivered.indexOfFirst { + it is StoreResult.Error && it.error is StoreError.Missing + } + val dataIndex = + delivered.indexOfFirst { + it is StoreResult.Data && it.value == "external" + } + assertTrue(delivered.any { it is StoreResult.Loading }) + assertTrue(missingIndex >= 0) + assertTrue(missingIndex < dataIndex) + assertTrue(assertIs>(delivered[dataIndex]).refreshing) + replacementStarted.await() + + releaseReplacement.complete(Unit) + assertEquals( + "recovered", + assertIs>(collector.awaitItem()).value, + ) + testScheduler.runCurrent() + collector.expectNoEvents() + assertEquals(3, calls) + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun mustNotModifiedReaderReplay_doesNotLaunchAThirdFetch() = runTest { + val key = TestKey("must-304-replay") + val sourceOfTruth = ReplayEveryRowSourceOfTruth() + val readerDeliveryGate = InitialDeliveryGate() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> FetcherResult.NotModified(etag = "e2") + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderDeliveryTestGate = readerDeliveryGate::awaitIfArmed, + ) + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + engine.stream(Freshness.MustBeFresh).test { + assertIs(awaitItem()) + assertEquals(Duration.ZERO, assertIs(awaitItem()).age) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + // Cross the reader-delivery gate after the direct 304 completion so the assertion is + // causally downstream of an actual equal-value replay, not merely its upstream emit. + readerDeliveryGate.arm() + sourceOfTruth.publish("v1") + readerDeliveryGate.entered.await() + expectNoEvents() + readerDeliveryGate.release() + testScheduler.runCurrent() + assertEquals(2, calls) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun pendingNotModified_sameValueReplayWaitsForWatcherDelivery() = runTest { + val key = TestKey("pending-304-replay") + val sourceOfTruth = StartupRaceSourceOfTruth() + val bookkeeper = GateSuccessBookkeeper() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> FetcherResult.NotModified(etag = "e2") + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = bookkeeper, + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + ) + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + app.cash.turbine.turbineScope { + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + assertEquals("v1", assertIs>(collector.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + bookkeeper.gateNextSuccess() + + engine.invalidate() + bookkeeper.successEntered.await() + sourceOfTruth.publish("v1") + testScheduler.runCurrent() + collector.expectNoEvents() + + bookkeeper.releaseSuccess.complete(Unit) + assertEquals( + Duration.ZERO, + assertIs(collector.awaitItem()).age, + ) + assertEquals(2, calls) + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun mappedReplayDeliveredBeforeCompletedNotModifiedWatcher_doesNotLaunchReplacement() = + runTest { + val key = TestKey("mapped-replay-before-304-watcher") + val sourceOfTruth = StartupRaceSourceOfTruth() + val readerDeliveryGate = InitialDeliveryGate() + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> { + fetchStarted.complete(Unit) + releaseFetch.await() + FetcherResult.NotModified(etag = "e1") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderDeliveryTestGate = readerDeliveryGate::awaitIfArmed, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals( + "seed", + assertIs>(observer.awaitItem()).value, + ) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + readerDeliveryGate.arm() + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + val baseline = assertIs>(collector.awaitItem()) + assertEquals("seed", baseline.value) + assertTrue(baseline.isStale) + assertTrue(baseline.refreshing) + fetchStarted.await() + val ticket = assertIs(engine.state.value.fetch).ticket + readerDeliveryGate.entered.await() + + releaseFetch.complete(Unit) + assertIs(ticket.outcome.await()) + collector.expectNoEvents() + readerDeliveryGate.release() + + assertEquals( + Duration.ZERO, + assertIs(collector.awaitItem()).age, + ) + assertEquals(1, calls) + observer.cancelAndIgnoreRemainingEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun getNotModifiedInvalidatedDuringOutcomeTail_retriesBeforeReturning() = runTest { + val key = TestKey("get-304-invalidated-tail") + val outcomeDeliveryGate = InitialDeliveryGate() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> FetcherResult.NotModified(etag = "e2") + 3 -> FetcherResult.Success("v3", etag = "e3") + else -> error("unexpected fetch call $calls") + } + }, + sot = InMemorySourceOfTruth(), + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeTicketOutcomeDeliveryTestGate = outcomeDeliveryGate::awaitIfArmed, + ) + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + outcomeDeliveryGate.arm() + val result = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.get(Freshness.MustBeFresh) + } + try { + withTimeout(1_000) { outcomeDeliveryGate.entered.await() } + withTimeout(1_000) { engine.invalidate() } + outcomeDeliveryGate.release() + + assertEquals("v3", withTimeout(1_000) { result.await() }) + assertEquals(3, calls) + } finally { + outcomeDeliveryGate.release() + } + } + + @Test + fun nonOwnerFailureHandoff_cannotLaunderAnotherTicketsRevalidation() = runTest { + val key = TestKey("non-owner-304") + val sourceOfTruth = StartupRaceSourceOfTruth() + val bookkeeper = GateFailureBookkeeper() + val outcomeDeliveryGate = InitialDeliveryGate() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> FetcherResult.Error(IllegalStateException("offline")) + 3 -> FetcherResult.NotModified(etag = "e2") + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = bookkeeper, + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeTicketOutcomeDeliveryTestGate = outcomeDeliveryGate::awaitIfArmed, + ) + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + app.cash.turbine.turbineScope { + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + assertEquals("v1", assertIs>(collector.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + bookkeeper.gateNextFailure() + + engine.invalidate() + bookkeeper.failureEntered.await() + outcomeDeliveryGate.arm() + bookkeeper.releaseFailure.complete(Unit) + outcomeDeliveryGate.entered.await() + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + outcomeDeliveryGate.release() + assertIs(assertIs(collector.awaitItem()).error) + collector.expectNoEvents() + assertEquals(3, calls) + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun passiveInitialRecapture_keepsPre304MemoryBaseline() = runTest { + val key = TestKey("passive-initial-304") + val sourceOfTruth = StartupRaceSourceOfTruth() + val initialGate = InitialDeliveryGate().also { it.arm() } + val clock = FakeWallClock(now = 0L) + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> FetcherResult.NotModified(etag = "e2") + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = clock, + engineScope = backgroundScope, + beforeInitialDeliveryTestGate = initialGate::awaitIfArmed, + ) + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + clock.now = 10.minutes.inWholeMilliseconds + app.cash.turbine.turbineScope { + val passive = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + initialGate.entered.await() + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + initialGate.release() + + val baseline = assertIs>(passive.awaitItem()) + assertEquals("v1", baseline.value) + assertEquals(10.minutes, baseline.age) + assertEquals(2, calls) + passive.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun foreignNotModifiedOwner_maxAgePlansFromVisibleBaselineAfterClockCrossing() = runTest { + val key = TestKey("foreign-304-max-age") + val sourceOfTruth = ReplayEveryRowSourceOfTruth() + val clock = FakeWallClock(now = 0L) + val bookkeeper = GateSuccessBookkeeper() + val replacementStarted = CompletableDeferred() + val releaseReplacement = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> FetcherResult.NotModified(etag = "e2") + 3 -> { + replacementStarted.complete(Unit) + releaseReplacement.await() + FetcherResult.NotModified(etag = "e3") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = bookkeeper, + validator = DefaultFreshnessValidator, + wallClock = clock, + engineScope = backgroundScope, + ) + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + app.cash.turbine.turbineScope { + val passive = + engine.stream(Freshness.MaxAge(10.minutes)).testIn(backgroundScope) + assertEquals("v1", assertIs>(passive.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + clock.now = 9.minutes.inWholeMilliseconds + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + passive.expectNoEvents() + + clock.now = 11.minutes.inWholeMilliseconds + sourceOfTruth.publish("v1") + assertIs(passive.awaitItem()) + replacementStarted.await() + passive.expectNoEvents() + + bookkeeper.gateNextSuccess() + releaseReplacement.complete(Unit) + bookkeeper.successEntered.await() + passive.expectNoEvents() + bookkeeper.releaseSuccess.complete(Unit) + assertEquals( + 2.minutes, + assertIs(passive.awaitItem()).age, + ) + testScheduler.runCurrent() + passive.expectNoEvents() + assertEquals(3, calls) + passive.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun maxAgeLaunchWithheld_backwardClockDuringNotModifiedTail_staysLoading() = runTest { + val key = TestKey("max-age-backward-clock-304") + val sourceOfTruth = StartupRaceSourceOfTruth() + val clock = FakeWallClock(now = 0L) + val bookkeeper = GateSuccessBookkeeper() + val initialGate = InitialDeliveryGate().also { it.arm() } + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> FetcherResult.NotModified(etag = "e2") + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = bookkeeper, + validator = DefaultFreshnessValidator, + wallClock = clock, + engineScope = backgroundScope, + beforeInitialDeliveryTestGate = initialGate::awaitIfArmed, + ) + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + clock.now = 10.minutes.inWholeMilliseconds + bookkeeper.gateNextSuccess() + app.cash.turbine.turbineScope { + val collector = + engine.stream(Freshness.MaxAge(5.minutes)).testIn(backgroundScope) + initialGate.entered.await() + bookkeeper.successEntered.await() + clock.now = 0L + initialGate.release() + + assertIs(collector.awaitItem()) + collector.expectNoEvents() + bookkeeper.releaseSuccess.complete(Unit) + + assertEquals( + 10.minutes, + assertIs(collector.awaitItem()).age, + ) + assertEquals(2, calls) + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun mappedDifferentRow_survivesForeignNotModifiedMetadataConvergence() = runTest { + val key = TestKey("mapped-row-owner-convergence") + val sourceOfTruth = StartupRaceSourceOfTruth() + val readerDeliveryGate = InitialDeliveryGate() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + calls += 1 + FetcherResult.NotModified(etag = "e$calls") + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderDeliveryTestGate = readerDeliveryGate::awaitIfArmed, + ) + + app.cash.turbine.turbineScope { + val passive = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(passive.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + readerDeliveryGate.arm() + sourceOfTruth.publish("external") + readerDeliveryGate.entered.await() + assertEquals("external", engine.get(Freshness.MustBeFresh)) + + readerDeliveryGate.release() + val changed = assertIs>(passive.awaitItem()) + assertEquals("external", changed.value) + assertEquals(Origin.SOT, changed.origin) + passive.expectNoEvents() + assertEquals(1, calls) + passive.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun readerFirstAuthoritativeAbsent_loadsBeforeGatedFailureOutcome() = runTest { + val key = TestKey("reader-first-absent") + val sourceOfTruth = StartupRaceSourceOfTruth() + val outcomeDeliveryGate = InitialDeliveryGate() + val replacementStarted = CompletableDeferred() + val releaseReplacement = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1") + 2 -> FetcherResult.Error(IllegalStateException("offline")) + 3 -> { + replacementStarted.complete(Unit) + releaseReplacement.await() + FetcherResult.Success("fresh") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeTicketOutcomeDeliveryTestGate = outcomeDeliveryGate::awaitIfArmed, + ) + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + app.cash.turbine.turbineScope { + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + assertEquals("v1", assertIs>(collector.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + outcomeDeliveryGate.arm() + engine.invalidate() + outcomeDeliveryGate.entered.await() + sourceOfTruth.publishAbsent() + + assertIs(collector.awaitItem()) + collector.expectNoEvents() + outcomeDeliveryGate.release() + assertIs(assertIs(collector.awaitItem()).error) + replacementStarted.await() + releaseReplacement.complete(Unit) + assertEquals("fresh", assertIs>(collector.awaitItem()).value) + assertEquals(3, calls) + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun completedFailureRetainsDemandAcrossLateEqualReplay_forCachedAndStaleIfError() = + runTest { + app.cash.turbine.turbineScope { + listOf(Freshness.CachedOrFetch, Freshness.StaleIfError).forEachIndexed { + index, + freshness, + -> + val key = TestKey("failed-late-equal-replay-$index") + val mappingGate = InitialDeliveryGate().also { it.arm() } + val secondStarted = CompletableDeferred() + val releaseSecond = CompletableDeferred() + val thirdStarted = CompletableDeferred() + val failure = IllegalStateException("offline-$index") + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1") + 2 -> { + secondStarted.complete(Unit) + releaseSecond.await() + FetcherResult.Error(failure) + } + + 3 -> { + thirdStarted.complete(Unit) + FetcherResult.Success("unexpected") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = InMemorySourceOfTruth(), + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = mappingGate::awaitIfArmed, + ) + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + engine.invalidate() + val collector = engine.stream(freshness).testIn(backgroundScope) + try { + val stale = assertIs>(collector.awaitItem()) + assertEquals("v1", stale.value) + assertTrue(stale.isStale) + assertTrue(stale.refreshing) + secondStarted.await() + mappingGate.entered.await() + + releaseSecond.complete(Unit) + val error = assertIs(collector.awaitItem()) + assertTrue(error.servedStale) + assertEquals(2, calls) + + mappingGate.release() + testScheduler.runCurrent() + assertFalse( + thirdStarted.isCompleted, + "equal late replay must not launch a third fetch for $freshness", + ) + collector.expectNoEvents() + } finally { + releaseSecond.complete(Unit) + mappingGate.release() + collector.cancelAndIgnoreRemainingEvents() + } + } + } + } + + @Test + fun completedFailureRetainsDemandAcrossLateNullReplay() = runTest { + val key = TestKey("failed-late-null-replay") + val mappingGate = InitialDeliveryGate().also { it.arm() } + val firstStarted = CompletableDeferred() + val releaseFirst = CompletableDeferred() + val secondStarted = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> { + firstStarted.complete(Unit) + releaseFirst.await() + FetcherResult.Error(IllegalStateException("offline")) + } + + 2 -> { + secondStarted.complete(Unit) + FetcherResult.Success("unexpected") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = InMemorySourceOfTruth(), + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = mappingGate::awaitIfArmed, + ) + + engine.stream(Freshness.CachedOrFetch).test { + assertIs(awaitItem()) + firstStarted.await() + mappingGate.entered.await() + + releaseFirst.complete(Unit) + assertIs(assertIs(awaitItem()).error) + assertEquals(1, calls) + + mappingGate.release() + testScheduler.runCurrent() + assertFalse( + secondStarted.isCompleted, + "duplicate late absence must not launch a second fetch", + ) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun outerTicketCommittedAfterInitialOutcomeCheck_emitsLoadingBeforeData() = runTest { + val key = TestKey("outer-ticket-final-classification") + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + val classificationGate = InitialDeliveryGate().also { it.arm() } + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + fetchStarted.complete(Unit) + releaseFetch.await() + FetcherResult.Success("fresh") + }, + sot = InMemorySourceOfTruth(), + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReplacementDispositionClassificationTestGate = + classificationGate::awaitIfArmed, + ) + + engine.stream(Freshness.CachedOrFetch).test { + fetchStarted.await() + classificationGate.entered.await() + releaseFetch.complete(Unit) + testScheduler.runCurrent() + classificationGate.release() + + assertIs(awaitItem()) + assertEquals("fresh", assertIs>(awaitItem()).value) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun outerTicketCommittedWithBaselineAfterInitialOutcomeCheck_emitsLoading() = runTest { + val key = TestKey("outer-ticket-committed-baseline") + val sourceOfTruth = ReplayEveryRowSourceOfTruth() + val mappingGate = InitialDeliveryGate() + val classificationGate = InitialDeliveryGate().also { it.arm() } + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + fetchStarted.complete(Unit) + releaseFetch.await() + FetcherResult.Success("fresh") + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = mappingGate::awaitIfArmed, + beforeReplacementDispositionClassificationTestGate = + classificationGate::awaitIfArmed, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + mappingGate.arm() + + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + classificationGate.entered.await() + fetchStarted.await() + val ticket = assertIs(engine.state.value.fetch).ticket + releaseFetch.complete(Unit) + mappingGate.entered.await() + assertIs(ticket.outcome.await()) + classificationGate.release() + + assertIs(collector.awaitItem()) + mappingGate.release() + assertEquals("fresh", assertIs>(collector.awaitItem()).value) + observer.cancelAndIgnoreRemainingEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun outerTicketCommittingAfterInitialOutcomeCheck_emitsServableBaseline() = runTest { + val key = TestKey("outer-ticket-committing-baseline") + val sourceOfTruth = GatedWriterReturnSourceOfTruth() + val classificationGate = InitialDeliveryGate().also { it.arm() } + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + fetchStarted.complete(Unit) + releaseFetch.await() + FetcherResult.Success("fresh") + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReplacementDispositionClassificationTestGate = + classificationGate::awaitIfArmed, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + engine.stream(Freshness.CachedOrFetch).test { + classificationGate.entered.await() + fetchStarted.await() + releaseFetch.complete(Unit) + sourceOfTruth.writeStarted.await() + classificationGate.release() + + val baseline = assertIs>(awaitItem()) + assertEquals("seed", baseline.value) + assertTrue(baseline.isStale) + assertFalse(baseline.refreshing) + expectNoEvents() + + sourceOfTruth.releaseWrite.complete(Unit) + val committed = assertIs>(awaitItem()) + assertEquals("fresh", committed.value) + assertEquals(Origin.FETCHER, committed.origin) + assertFalse(committed.refreshing) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun outerTicketRevalidatedAfterInitialOutcomeCheck_emitsBaselineThenFreshOwner() = runTest { + val key = TestKey("outer-ticket-revalidated-baseline") + val sourceOfTruth = StartupRaceSourceOfTruth() + val classificationGate = InitialDeliveryGate().also { it.arm() } + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + fetchStarted.complete(Unit) + releaseFetch.await() + FetcherResult.NotModified(etag = "e1") + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReplacementDispositionClassificationTestGate = + classificationGate::awaitIfArmed, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + engine.stream(Freshness.CachedOrFetch).test { + classificationGate.entered.await() + fetchStarted.await() + val ticket = assertIs(engine.state.value.fetch).ticket + releaseFetch.complete(Unit) + assertIs(ticket.outcome.await()) + classificationGate.release() + + val baseline = assertIs>(awaitItem()) + assertEquals("seed", baseline.value) + assertTrue(baseline.isStale) + assertFalse(baseline.refreshing) + + assertEquals(Duration.ZERO, assertIs(awaitItem()).age) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun outerTicketRevalidatedBeforeOutcomeTail_doesNotReserveReplacement() = runTest { + val key = TestKey("outer-ticket-revalidated-pending-tail") + val sourceOfTruth = StartupRaceSourceOfTruth() + val bookkeeper = PreDelegateSuccessGateBookkeeper().also { it.gateNextSuccess() } + val classificationGate = InitialDeliveryGate().also { it.arm() } + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> { + fetchStarted.complete(Unit) + releaseFetch.await() + FetcherResult.NotModified(etag = "e1") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = bookkeeper, + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReplacementDispositionClassificationTestGate = + classificationGate::awaitIfArmed, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + engine.invalidate() + app.cash.turbine.turbineScope { + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + try { + classificationGate.entered.await() + fetchStarted.await() + val ticket = assertIs(engine.state.value.fetch).ticket + releaseFetch.complete(Unit) + bookkeeper.successEntered.await() + assertIs(ticket.disposition.value) + assertFalse(ticket.outcome.isCompleted) + + classificationGate.release() + val baseline = assertIs>(collector.awaitItem()) + assertEquals("seed", baseline.value) + assertTrue(baseline.isStale) + assertFalse(baseline.refreshing) + assertEquals(1, calls) + + bookkeeper.releaseSuccess.complete(Unit) + assertIs(ticket.outcome.await()) + assertEquals( + Duration.ZERO, + assertIs(collector.awaitItem()).age, + ) + assertEquals(1, calls) + collector.expectNoEvents() + collector.cancelAndIgnoreRemainingEvents() + } finally { + classificationGate.release() + releaseFetch.complete(Unit) + bookkeeper.releaseSuccess.complete(Unit) + } + } + } + + @Test + fun cachedFastNotModified_emitsBaselineThenWatcherRefresh() = runTest { + val key = TestKey("cached-fast-304") + val sourceOfTruth = StartupRaceSourceOfTruth() + val gate = InitialDeliveryGate().also { it.arm() } + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + calls += 1 + fetchStarted.complete(Unit) + releaseFetch.await() + FetcherResult.NotModified(etag = "e1") + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeInitialDeliveryTestGate = gate::awaitIfArmed, + ) + + app.cash.turbine.turbineScope { + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + gate.entered.await() + fetchStarted.await() + val ticket = assertIs(engine.state.value.fetch).ticket + releaseFetch.complete(Unit) + assertIs(ticket.outcome.await()) + gate.release() + + val baseline = assertIs>(collector.awaitItem()) + assertEquals("seed", baseline.value) + assertEquals(Origin.SOT, baseline.origin) + assertTrue(baseline.isStale) + assertFalse(baseline.refreshing) + + assertEquals( + Duration.ZERO, + assertIs(collector.awaitItem()).age, + ) + testScheduler.runCurrent() + assertEquals(1, calls) + collector.expectNoEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun cachedFastNotModifiedInvalidatedBeforeDelivery_reservesBeforeStaleData() = runTest { + val key = TestKey("cached-fast-304-invalidated") + val sourceOfTruth = StartupRaceSourceOfTruth() + val initialGate = InitialDeliveryGate().also { it.arm() } + val revalidationStarted = CompletableDeferred() + val releaseRevalidation = CompletableDeferred() + val replacementStarted = CompletableDeferred() + val releaseReplacement = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> { + revalidationStarted.complete(Unit) + releaseRevalidation.await() + FetcherResult.NotModified(etag = "e1") + } + + 2 -> { + replacementStarted.complete(Unit) + releaseReplacement.await() + FetcherResult.Success("fresh", etag = "e2") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeInitialDeliveryTestGate = initialGate::awaitIfArmed, + ) + + app.cash.turbine.turbineScope { + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + initialGate.entered.await() + revalidationStarted.await() + val ticket = assertIs(engine.state.value.fetch).ticket + releaseRevalidation.complete(Unit) + assertIs(ticket.outcome.await()) + engine.invalidate() + initialGate.release() + + replacementStarted.await() + val stale = assertIs>(collector.awaitItem()) + assertEquals("seed", stale.value) + assertTrue(stale.isStale) + assertTrue(stale.refreshing) + + releaseReplacement.complete(Unit) + assertEquals("fresh", assertIs>(collector.awaitItem()).value) + assertEquals(2, calls) + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun mustNotModifiedInvalidatedBeforeDirectDelivery_launchesReplacement() = runTest { + val key = TestKey("must-304-invalidated") + val sourceOfTruth = StartupRaceSourceOfTruth() + val outcomeDeliveryGate = InitialDeliveryGate() + val replacementStarted = CompletableDeferred() + val releaseReplacement = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> FetcherResult.NotModified(etag = "e2") + 3 -> { + replacementStarted.complete(Unit) + releaseReplacement.await() + FetcherResult.Success("v3", etag = "e3") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeTicketOutcomeDeliveryTestGate = outcomeDeliveryGate::awaitIfArmed, + ) + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + outcomeDeliveryGate.arm() + app.cash.turbine.turbineScope { + val collector = engine.stream(Freshness.MustBeFresh).testIn(backgroundScope) + try { + val loading = withTimeoutOrNull(1_000) { collector.awaitItem() } + assertIs(loading, "initial Loading was not delivered") + assertTrue( + withTimeoutOrNull(1_000) { + outcomeDeliveryGate.entered.await() + true + } == true, + "ticket outcome delivery gate was not reached", + ) + withTimeout(1_000) { engine.invalidate() } + outcomeDeliveryGate.release() + + withTimeout(1_000) { replacementStarted.await() } + collector.expectNoEvents() + releaseReplacement.complete(Unit) + assertEquals( + "v3", + assertIs>( + withTimeout(1_000) { collector.awaitItem() }, + ).value, + ) + assertEquals(3, calls) + collector.cancelAndIgnoreRemainingEvents() + } finally { + outcomeDeliveryGate.release() + releaseReplacement.complete(Unit) + } + } + } + + @Test + fun mustNotModifiedInvalidatedBeforeDirectDelivery_replacementFailureIsTerminal() = + runTest { + val key = TestKey("must-304-invalidated-failure") + val sourceOfTruth = StartupRaceSourceOfTruth() + val outcomeDeliveryGate = InitialDeliveryGate() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> FetcherResult.NotModified(etag = "e2") + 3 -> FetcherResult.Error(IllegalStateException("offline")) + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeTicketOutcomeDeliveryTestGate = outcomeDeliveryGate::awaitIfArmed, + ) + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + outcomeDeliveryGate.arm() + engine.stream(Freshness.MustBeFresh).test { + try { + val loading = withTimeoutOrNull(1_000) { awaitItem() } + assertIs(loading, "initial Loading was not delivered") + assertTrue( + withTimeoutOrNull(1_000) { + outcomeDeliveryGate.entered.await() + true + } == true, + "ticket outcome delivery gate was not reached", + ) + withTimeout(1_000) { engine.invalidate() } + outcomeDeliveryGate.release() + + assertIs( + assertIs(withTimeout(1_000) { awaitItem() }).error, + ) + awaitComplete() + assertEquals(3, calls) + } finally { + outcomeDeliveryGate.release() + } + } + } + + @Test + fun initialDeliveryGate_equalContentNewRevisionIsNeverRestampedMemory() = runTest { + val key = TestKey("memory-race") + val sourceOfTruth = StartupRaceSourceOfTruth() + val gate = InitialDeliveryGate() + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { error("fetch must not run") }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeInitialDeliveryTestGate = gate::awaitIfArmed, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + gate.arm() + + val raced = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + gate.entered.await() + sourceOfTruth.publish("other") + assertEquals("other", assertIs>(observer.awaitItem()).value) + sourceOfTruth.publish("seed") + val replacement = assertIs>(observer.awaitItem()) + assertEquals("seed", replacement.value) + assertEquals(Origin.SOT, replacement.origin) + + gate.release() + val first = assertIs>(raced.awaitItem()) + assertEquals("seed", first.value) + assertEquals(Origin.SOT, first.origin) + observer.cancelAndIgnoreRemainingEvents() + raced.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun failedInitialTicket_replansAResidenceThatChangedBeforeFinalDelivery() = runTest { + val key = TestKey("failed-race") + val sourceOfTruth = StartupRaceSourceOfTruth() + val gate = InitialDeliveryGate() + val bookkeeper = FailureSignallingBookkeeper() + val secondStarted = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Error(IllegalStateException("offline")) + 2 -> { + secondStarted.complete(Unit) + FetcherResult.Success("fresh") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = bookkeeper, + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeInitialDeliveryTestGate = gate::awaitIfArmed, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + gate.arm() + + val raced = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + gate.entered.await() + bookkeeper.failureRecorded.await() + sourceOfTruth.publish("external") + assertEquals("external", assertIs>(observer.awaitItem()).value) + gate.release() + + val external = assertIs>(raced.awaitItem()) + assertEquals("external", external.value) + assertEquals(Origin.SOT, external.origin) + assertTrue(external.refreshing) + assertIs(assertIs(raced.awaitItem()).error) + secondStarted.await() + assertEquals("fresh", assertIs>(raced.awaitItem()).value) + assertEquals(2, calls) + observer.cancelAndIgnoreRemainingEvents() + raced.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun fastReplacementCommit_handsOffNewerRowBeforeSavedFailure() = runTest { + val key = TestKey("fast-replacement-commit") + val sourceOfTruth = StartupRaceSourceOfTruth() + val initialGate = InitialDeliveryGate() + val replacementDispositionGate = InitialDeliveryGate().also { it.arm() } + val bookkeeper = GateSuccessBookkeeper() + val recoveryStarted = CompletableDeferred() + val releaseRecovery = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Error(IllegalStateException("old failure")) + 2 -> FetcherResult.Success("replacement") + 3 -> { + recoveryStarted.complete(Unit) + releaseRecovery.await() + FetcherResult.Success("recovered") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = bookkeeper, + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeInitialDeliveryTestGate = initialGate::awaitIfArmed, + beforeReplacementDispositionClassificationTestGate = + replacementDispositionGate::awaitIfArmed, + ) + + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + initialGate.arm() + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + initialGate.entered.await() + bookkeeper.failureRecorded.await() + sourceOfTruth.publish("external-1") + assertEquals( + "external-1", + assertIs>(observer.awaitItem()).value, + ) + + bookkeeper.gateNextSuccess() + initialGate.release() + replacementDispositionGate.entered.await() + bookkeeper.successEntered.await() + sourceOfTruth.publish("external-2") + while (true) { + val observed = assertIs>(observer.awaitItem()) + if (observed.value == "external-2") break + } + replacementDispositionGate.release() + collector.expectNoEvents() + + bookkeeper.releaseSuccess.complete(Unit) + val handedOff = assertIs>(collector.awaitItem()) + assertEquals("external-2", handedOff.value) + assertTrue(handedOff.refreshing) + assertIs(assertIs(collector.awaitItem()).error) + recoveryStarted.await() + + releaseRecovery.complete(Unit) + assertEquals( + "recovered", + assertIs>(collector.awaitItem()).value, + ) + assertEquals(3, calls) + observer.cancelAndIgnoreRemainingEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun fastReplacementNotModified_deliversBaselineThenOwnerBeforeSavedFailure() = runTest { + val key = TestKey("fast-replacement-304") + val sourceOfTruth = StartupRaceSourceOfTruth() + val initialGate = InitialDeliveryGate() + val replacementDispositionGate = InitialDeliveryGate().also { it.arm() } + val bookkeeper = GateSuccessBookkeeper() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Error(IllegalStateException("old failure")) + 2 -> FetcherResult.NotModified(etag = "e2") + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = bookkeeper, + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeInitialDeliveryTestGate = initialGate::awaitIfArmed, + beforeReplacementDispositionClassificationTestGate = + replacementDispositionGate::awaitIfArmed, + ) + + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + initialGate.arm() + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + initialGate.entered.await() + bookkeeper.failureRecorded.await() + sourceOfTruth.publish("external") + assertEquals( + "external", + assertIs>(observer.awaitItem()).value, + ) + + bookkeeper.gateNextSuccess() + initialGate.release() + replacementDispositionGate.entered.await() + bookkeeper.successEntered.await() + replacementDispositionGate.release() + + val baseline = assertIs>(collector.awaitItem()) + assertEquals("external", baseline.value) + assertEquals(Origin.SOT, baseline.origin) + assertTrue(baseline.isStale) + collector.expectNoEvents() + + bookkeeper.releaseSuccess.complete(Unit) + assertEquals( + Duration.ZERO, + assertIs(collector.awaitItem()).age, + ) + assertIs(assertIs(collector.awaitItem()).error) + assertEquals(2, calls) + observer.cancelAndIgnoreRemainingEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun pendingInitialCommitTail_defersNewerRowUntilReplacementIsReserved() = runTest { + val key = TestKey("pending-initial-tail") + val sourceOfTruth = StartupRaceSourceOfTruth() + val initialGate = InitialDeliveryGate() + val bookkeeper = GateSuccessBookkeeper() + val replacementStarted = CompletableDeferred() + val releaseReplacement = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("candidate") + 2 -> { + replacementStarted.complete(Unit) + releaseReplacement.await() + FetcherResult.Success("fresh") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = bookkeeper, + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeInitialDeliveryTestGate = initialGate::awaitIfArmed, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + bookkeeper.gateNextSuccess() + initialGate.arm() + + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + initialGate.entered.await() + bookkeeper.successEntered.await() + sourceOfTruth.publish("external") + while (true) { + val item = observer.awaitItem() + if (item is StoreResult.Data && item.value == "external") break + } + initialGate.release() + + collector.expectNoEvents() + + bookkeeper.releaseSuccess.complete(Unit) + replacementStarted.await() + val handedOff = assertIs>(collector.awaitItem()) + assertEquals("external", handedOff.value) + assertTrue(handedOff.refreshing) + releaseReplacement.complete(Unit) + assertEquals("fresh", assertIs>(collector.awaitItem()).value) + assertEquals(2, calls) + observer.cancelAndIgnoreRemainingEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun joinedOlderTicketFailure_replansTheCollectorsNewerResidence() = runTest { + val key = TestKey("joined-failure") + val sourceOfTruth = StartupRaceSourceOfTruth() + val deliveryGate = InitialDeliveryGate() + val secondStarted = CompletableDeferred() + val releaseSecond = CompletableDeferred() + val replacementStarted = CompletableDeferred() + val releaseReplacement = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1") + 2 -> { + secondStarted.complete(Unit) + releaseSecond.await() + FetcherResult.Error(IllegalStateException("offline")) + } + + 3 -> { + replacementStarted.complete(Unit) + releaseReplacement.await() + FetcherResult.Success("recovered") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderDeliveryTestGate = deliveryGate::awaitIfArmed, + ) + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + app.cash.turbine.turbineScope { + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + assertEquals("v1", assertIs>(collector.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + val owner = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { engine.get(Freshness.MustBeFresh) } + } + secondStarted.await() + val failedTicket = assertIs(engine.state.value.fetch).ticket + sourceOfTruth.publish("external-1") + + val external = assertIs>(collector.awaitItem()) + assertEquals("external-1", external.value) + assertTrue(external.refreshing) + deliveryGate.arm() + sourceOfTruth.publish("external-2") + deliveryGate.entered.await() + releaseSecond.complete(Unit) + + assertIs(failedTicket.outcome.await()) + assertTrue(owner.await().isFailure) + deliveryGate.release() + + val handedOff = assertIs>(collector.awaitItem()) + assertEquals("external-2", handedOff.value) + assertTrue(handedOff.refreshing) + assertIs(assertIs(collector.awaitItem()).error) + replacementStarted.await() + releaseReplacement.complete(Unit) + assertEquals( + "recovered", + assertIs>(collector.awaitItem()).value, + ) + assertEquals(3, calls) + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun postInitialMustFailure_replansANewerResidence() = runTest { + val key = TestKey("live-must-failure") + val sourceOfTruth = StartupRaceSourceOfTruth() + val secondStarted = CompletableDeferred() + val releaseSecond = CompletableDeferred() + val replacementStarted = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1") + 2 -> { + secondStarted.complete(Unit) + releaseSecond.await() + FetcherResult.Error(IllegalStateException("offline")) + } + + 3 -> { + replacementStarted.complete(Unit) + FetcherResult.Success("recovered") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + ) + + app.cash.turbine.turbineScope { + val must = engine.stream(Freshness.MustBeFresh).testIn(backgroundScope) + assertIs(must.awaitItem()) + assertEquals("v1", assertIs>(must.awaitItem()).value) + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("v1", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + engine.invalidate() + assertIs(must.awaitItem()) + secondStarted.await() + sourceOfTruth.publish("external") + assertEquals( + "external", + assertIs>(observer.awaitItem()).value, + ) + releaseSecond.complete(Unit) + + assertIs(assertIs(must.awaitItem()).error) + replacementStarted.await() + assertEquals("recovered", assertIs>(must.awaitItem()).value) + assertEquals(3, calls) + observer.cancelAndIgnoreRemainingEvents() + must.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun replacementFailure_reportsTheStaleValueThatRemainedVisible() = runTest { + val key = TestKey("replacement-stale") + val sourceOfTruth = StartupRaceSourceOfTruth() + val secondStarted = CompletableDeferred() + val releaseSecond = CompletableDeferred() + val replacementStarted = CompletableDeferred() + val releaseReplacement = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1") + 2 -> { + secondStarted.complete(Unit) + releaseSecond.await() + FetcherResult.Error(IllegalStateException("first failure")) + } + + 3 -> { + replacementStarted.complete(Unit) + releaseReplacement.await() + FetcherResult.Error(IllegalStateException("second failure")) + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + ) + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + app.cash.turbine.turbineScope { + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + assertEquals("v1", assertIs>(collector.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + val owner = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { engine.get(Freshness.MustBeFresh) } + } + secondStarted.await() + sourceOfTruth.publish("external") + val stale = assertIs>(collector.awaitItem()) + assertEquals("external", stale.value) + assertTrue(stale.isStale) + assertTrue(stale.refreshing) + + releaseSecond.complete(Unit) + assertTrue(owner.await().isFailure) + val firstFailure = assertIs(collector.awaitItem()) + assertTrue(firstFailure.servedStale) + replacementStarted.await() + releaseReplacement.complete(Unit) + + val secondFailure = assertIs(collector.awaitItem()) + assertTrue(secondFailure.servedStale) + assertEquals(3, calls) + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun pendingFailureTail_handsOffNewerRowBeforeSurfacingError() = runTest { + val key = TestKey("pending-failure-tail") + val sourceOfTruth = StartupRaceSourceOfTruth() + val bookkeeper = GateFailureBookkeeper() + val secondStarted = CompletableDeferred() + val releaseSecond = CompletableDeferred() + val replacementStarted = CompletableDeferred() + val releaseReplacement = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1") + 2 -> { + secondStarted.complete(Unit) + releaseSecond.await() + FetcherResult.Error(IllegalStateException("offline")) + } + + 3 -> { + replacementStarted.complete(Unit) + releaseReplacement.await() + FetcherResult.Success("fresh") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = bookkeeper, + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + ) + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + app.cash.turbine.turbineScope { + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + assertEquals("v1", assertIs>(collector.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + bookkeeper.gateNextFailure() + + val owner = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { engine.get(Freshness.MustBeFresh) } + } + secondStarted.await() + sourceOfTruth.publish("external-1") + assertEquals( + "external-1", + assertIs>(collector.awaitItem()).value, + ) + releaseSecond.complete(Unit) + bookkeeper.failureEntered.await() + + sourceOfTruth.publish("external-2") + testScheduler.runCurrent() + collector.expectNoEvents() + bookkeeper.releaseFailure.complete(Unit) + assertTrue(owner.await().isFailure) + + val handedOff = assertIs>(collector.awaitItem()) + assertEquals("external-2", handedOff.value) + assertTrue(handedOff.refreshing) + assertIs(assertIs(collector.awaitItem()).error) + replacementStarted.await() + releaseReplacement.complete(Unit) + assertEquals("fresh", assertIs>(collector.awaitItem()).value) + assertEquals(3, calls) + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun completedFailureTail_queuedAuthoritativeAbsentLoadsBeforeSavedError() = runTest { + val key = TestKey("completed-failure-absent") + val sourceOfTruth = StartupRaceSourceOfTruth() + val bookkeeper = GateFailureBookkeeper() + val readerDeliveryGate = InitialDeliveryGate() + val replacementStarted = CompletableDeferred() + val releaseReplacement = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1") + 2 -> FetcherResult.Error(IllegalStateException("offline")) + 3 -> { + replacementStarted.complete(Unit) + releaseReplacement.await() + FetcherResult.Success("fresh") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = bookkeeper, + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderDeliveryTestGate = readerDeliveryGate::awaitIfArmed, + ) + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + app.cash.turbine.turbineScope { + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + assertEquals("v1", assertIs>(collector.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + bookkeeper.gateNextFailure() + + engine.invalidate() + bookkeeper.failureEntered.await() + readerDeliveryGate.arm() + sourceOfTruth.publish("queued-row") + readerDeliveryGate.entered.await() + + bookkeeper.releaseFailure.complete(Unit) + testScheduler.runCurrent() + sourceOfTruth.publishAbsent() + testScheduler.runCurrent() + assertTrue(runCatching { engine.get(Freshness.LocalOnly) }.isFailure) + readerDeliveryGate.release() + + assertIs(collector.awaitItem()) + assertIs(assertIs(collector.awaitItem()).error) + replacementStarted.await() + releaseReplacement.complete(Unit) + assertEquals("fresh", assertIs>(collector.awaitItem()).value) + assertEquals(3, calls) + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun coldPreWriteAbsent_cannotBecomePostCutoffAuthority() = runTest { + val key = TestKey("cold-pre-write-absent") + val sourceOfTruth = HeldColdObservationSourceOfTruth() + val fetchStarted = CompletableDeferred() + val releaseFirstFetch = CompletableDeferred() + val releaseUnexpectedFetch = CompletableDeferred() + val outcomeDeliveryGate = InitialDeliveryGate().also { it.arm() } + val planningContexts = mutableListOf() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> { + fetchStarted.complete(Unit) + releaseFirstFetch.await() + FetcherResult.Success("fresh") + } + + 2 -> { + releaseUnexpectedFetch.await() + FetcherResult.Success("unexpected") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = + object : FreshnessValidator { + override fun plan(context: FreshnessContext): FetchPlan { + planningContexts += context + return DefaultFreshnessValidator.plan(context) + } + }, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeTicketOutcomeDeliveryTestGate = outcomeDeliveryGate::awaitIfArmed, + ) + + app.cash.turbine.turbineScope { + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + assertIs(collector.awaitItem()) + sourceOfTruth.coldObservationHeld.await() + fetchStarted.await() + val ticket = assertIs(engine.state.value.fetch).ticket + + releaseFirstFetch.complete(Unit) + assertIs(ticket.outcome.await()) + outcomeDeliveryGate.entered.await() + testScheduler.advanceTimeBy( + (COLD_OBSERVATION_HOLD + WRITER_CURRENT_DELIVERY_GAP).inWholeMilliseconds, + ) + testScheduler.runCurrent() + + val data = assertIs>(collector.awaitItem()) + val committedContext = planningContexts.last { it.hasResidentValue } + assertEquals( + ColdPathContractObservation( + origin = Origin.FETCHER, + isStale = false, + refreshing = false, + hasMeta = true, + fetchCalls = 1, + ), + ColdPathContractObservation( + origin = data.origin, + isStale = data.isStale, + refreshing = data.refreshing, + hasMeta = committedContext.meta != null, + fetchCalls = calls, + ), + ) + + collector.cancelAndIgnoreRemainingEvents() + outcomeDeliveryGate.release() + releaseUnexpectedFetch.complete(Unit) + } + } + + @Test + fun coldPreWriteAbsent_twoCollectorsCannotLaunchMirrorFetch() = runTest { + val key = TestKey("cold-pre-write-absent-two-collectors") + val sourceOfTruth = HeldColdObservationSourceOfTruth(immediateReaderCalls = 2) + val fetchStarted = CompletableDeferred() + val releaseFirstFetch = CompletableDeferred() + val releaseUnexpectedFetch = CompletableDeferred() + val initialDeliveryGate = SequencedGate() + val collectorAInitial = initialDeliveryGate.gateNext() + val collectorBInitial = initialDeliveryGate.gateNext() + val outcomeDeliveryGate = SequencedGate() + val collectorAOutcome = outcomeDeliveryGate.gateNext() + val collectorBOutcome = outcomeDeliveryGate.gateNext() + val planningContexts = mutableListOf() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> { + fetchStarted.complete(Unit) + releaseFirstFetch.await() + FetcherResult.Success("fresh") + } + + 2 -> { + releaseUnexpectedFetch.await() + FetcherResult.Success("unexpected") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = + object : FreshnessValidator { + override fun plan(context: FreshnessContext): FetchPlan { + planningContexts += context + return DefaultFreshnessValidator.plan(context) + } + }, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeInitialDeliveryTestGate = initialDeliveryGate::awaitIfQueued, + beforeTicketOutcomeDeliveryTestGate = outcomeDeliveryGate::awaitIfQueued, + ) + + app.cash.turbine.turbineScope { + val collectorA = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + val collectorB = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + collectorAInitial.entered.await() + collectorBInitial.entered.await() + fetchStarted.await() + val ticket = assertIs(engine.state.value.fetch).ticket + assertEquals(1, calls) + assertFalse(sourceOfTruth.coldObservationHeld.isCompleted) + collectorAInitial.release.complete(Unit) + collectorBInitial.release.complete(Unit) + assertIs(collectorA.awaitItem()) + assertIs(collectorB.awaitItem()) + sourceOfTruth.coldObservationHeld.await() + + releaseFirstFetch.complete(Unit) + assertIs(ticket.outcome.await()) + collectorAOutcome.entered.await() + collectorBOutcome.entered.await() + testScheduler.advanceTimeBy( + (COLD_OBSERVATION_HOLD + WRITER_CURRENT_DELIVERY_GAP).inWholeMilliseconds, + ) + testScheduler.runCurrent() + + val data = + listOf( + assertIs>(collectorA.awaitItem()), + assertIs>(collectorB.awaitItem()), + ) + val committedContext = planningContexts.last { it.hasResidentValue } + data.forEach { result -> + assertEquals( + ColdPathContractObservation( + origin = Origin.FETCHER, + isStale = false, + refreshing = false, + hasMeta = true, + fetchCalls = 1, + ), + ColdPathContractObservation( + origin = result.origin, + isStale = result.isStale, + refreshing = result.refreshing, + hasMeta = committedContext.meta != null, + fetchCalls = calls, + ), + ) + } + + collectorA.cancelAndIgnoreRemainingEvents() + collectorB.cancelAndIgnoreRemainingEvents() + collectorAOutcome.release.complete(Unit) + collectorBOutcome.release.complete(Unit) + releaseUnexpectedFetch.complete(Unit) + } + } + + @Test + fun successorWrite_cannotRetireQueuedPredecessorEchoFence() = runTest { + val key = TestKey("successor-write-predecessor-echo") + val sourceOfTruth = ConsecutiveWriteEchoSourceOfTruth() + val fetchStarted = CompletableDeferred() + val releaseFirstFetch = CompletableDeferred() + val outcomeDeliveryGate = InitialDeliveryGate().also { it.arm() } + val planningContexts = mutableListOf() + val unexpectedFetchStarted = CompletableDeferred() + val releaseUnexpectedFetch = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> { + fetchStarted.complete(Unit) + releaseFirstFetch.await() + FetcherResult.Success("first") + } + + 2 -> { + unexpectedFetchStarted.complete(Unit) + releaseUnexpectedFetch.await() + FetcherResult.Success("unexpected") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = + object : FreshnessValidator { + override fun plan(context: FreshnessContext): FetchPlan { + planningContexts += context + return DefaultFreshnessValidator.plan(context) + } + }, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeTicketOutcomeDeliveryTestGate = outcomeDeliveryGate::awaitIfArmed, + ) + + app.cash.turbine.turbineScope { + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + assertIs(collector.awaitItem()) + fetchStarted.await() + sourceOfTruth.liveReaderStarted.await() + val ticket = assertIs(engine.state.value.fetch).ticket + releaseFirstFetch.complete(Unit) + sourceOfTruth.firstEchoHeld.await() + assertIs(ticket.outcome.await()) + outcomeDeliveryGate.entered.await() + + val successor = backgroundScope.async { engine.applyWrite("second") } + try { + sourceOfTruth.secondWriteStarted.await() + sourceOfTruth.releaseFirstEcho.complete(Unit) + testScheduler.runCurrent() + + val first = assertIs>(collector.awaitItem()) + val committedContext = planningContexts.last { it.hasResidentValue } + assertEquals( + ColdPathContractObservation( + origin = Origin.FETCHER, + isStale = false, + refreshing = false, + hasMeta = true, + fetchCalls = 1, + ), + ColdPathContractObservation( + origin = first.origin, + isStale = first.isStale, + refreshing = first.refreshing, + hasMeta = committedContext.meta != null, + fetchCalls = calls, + ), + ) + assertFalse(unexpectedFetchStarted.isCompleted) + } finally { + sourceOfTruth.releaseFirstEcho.complete(Unit) + sourceOfTruth.releaseSecondWriteReturn.complete(Unit) + sourceOfTruth.releaseSecondEcho.complete(Unit) + outcomeDeliveryGate.release() + releaseUnexpectedFetch.complete(Unit) + } + successor.await() + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun failedSuccessorWrite_cannotStripCommittedPredecessorEnvelope() = runTest { + val key = TestKey("failed-successor-preserves-predecessor") + val sourceOfTruth = FailingSuccessorSourceOfTruth() + val mappingGate = SequencedGate() + val firstFetchStarted = CompletableDeferred() + val releaseFirstFetch = CompletableDeferred() + val unexpectedFetchStarted = CompletableDeferred() + val releaseUnexpectedFetch = CompletableDeferred() + val planningContexts = mutableListOf() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> { + firstFetchStarted.complete(Unit) + releaseFirstFetch.await() + FetcherResult.Success("first") + } + + 2 -> { + unexpectedFetchStarted.complete(Unit) + releaseUnexpectedFetch.await() + FetcherResult.Success("unexpected") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = + object : FreshnessValidator { + override fun plan(context: FreshnessContext): FetchPlan { + planningContexts += context + return DefaultFreshnessValidator.plan(context) + } + }, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = mappingGate::awaitIfQueued, + ) + + app.cash.turbine.turbineScope { + val owner = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + try { + assertIs(owner.awaitItem()) + firstFetchStarted.await() + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + val firstTicket = assertIs(engine.state.value.fetch).ticket + releaseFirstFetch.complete(Unit) + withTimeout(3_000L) { sourceOfTruth.firstNotificationQueued.await() } + assertIs( + withTimeout(3_000L) { firstTicket.outcome.await() }, + ) + + val successorExact = mappingGate.gateNext() + val rollback = mappingGate.gateNext() + try { + val successor = + backgroundScope.async { + runCatching { engine.applyWrite("second") } + } + withTimeout(3_000L) { successorExact.entered.await() } + sourceOfTruth.releaseRollback.complete(Unit) + withTimeout(3_000L) { sourceOfTruth.rollbackRawCaptured.await() } + + successorExact.release.complete(Unit) + withTimeout(3_000L) { rollback.entered.await() } + rollback.release.complete(Unit) + testScheduler.runCurrent() + sourceOfTruth.releaseFailure.complete(Unit) + assertTrue(withTimeout(3_000L) { successor.await() }.isFailure) + testScheduler.runCurrent() + + val retained = assertIs>(owner.awaitItem()) + val retainedContext = planningContexts.last { it.hasResidentValue } + assertEquals( + ColdPathContractObservation( + origin = Origin.FETCHER, + isStale = false, + refreshing = false, + hasMeta = true, + fetchCalls = 1, + ), + ColdPathContractObservation( + origin = retained.origin, + isStale = retained.isStale, + refreshing = retained.refreshing, + hasMeta = retainedContext.meta != null, + fetchCalls = calls, + ), + ) + assertFalse(unexpectedFetchStarted.isCompleted) + } finally { + successorExact.release.complete(Unit) + rollback.release.complete(Unit) + sourceOfTruth.releaseRollback.complete(Unit) + sourceOfTruth.releaseFailure.complete(Unit) + releaseUnexpectedFetch.complete(Unit) + } + } finally { + sourceOfTruth.releaseFirstNotification.complete(Unit) + sourceOfTruth.releaseRollback.complete(Unit) + releaseFirstFetch.complete(Unit) + releaseUnexpectedFetch.complete(Unit) + owner.cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun replacementReaderSession_retiresUnresolvedPredecessorFence() = runTest { + val key = TestKey("replacement-retires-predecessor-fence") + val sourceOfTruth = ConsecutiveWriteEchoSourceOfTruth() + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + calls += 1 + fetchStarted.complete(Unit) + releaseFetch.await() + FetcherResult.Success("first") + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + ) + + app.cash.turbine.turbineScope { + val owner = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + assertIs(owner.awaitItem()) + fetchStarted.await() + sourceOfTruth.liveReaderStarted.await() + val ticket = assertIs(engine.state.value.fetch).ticket + releaseFetch.complete(Unit) + sourceOfTruth.firstEchoHeld.await() + assertIs(ticket.outcome.await()) + + owner.cancelAndIgnoreRemainingEvents() + testScheduler.advanceTimeBy(READER_PIPELINE_GRACE_MILLIS + 1L) + testScheduler.runCurrent() + + val replacement = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + sourceOfTruth.replacementReaderStarted.await() + sourceOfTruth.publishExternal("external") + var external: StoreResult.Data? = null + while (external == null) { + val item = replacement.awaitItem() + if (item is StoreResult.Data && item.value == "external") external = item + } + assertEquals(Origin.SOT, checkNotNull(external).origin) + assertEquals(1, calls) + + replacement.cancelAndIgnoreRemainingEvents() + sourceOfTruth.releaseFirstEcho.complete(Unit) + } + } + + @Test + fun olderPreStampRow_cannotOverwriteANewerWriterObservation() = runTest { + val key = TestKey("older-pre-stamp-row") + val sourceOfTruth = ReplayEveryRowSourceOfTruth() + val mappingGate = SequencedGate() + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + calls += 1 + fetchStarted.complete(Unit) + releaseFetch.await() + FetcherResult.Success("candidate") + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = mappingGate::awaitIfQueued, + ) + + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + val preStamp = mappingGate.gateNext() + val writerCurrent = mappingGate.gateNext() + sourceOfTruth.publish("older") + preStamp.entered.await() + + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + val stale = assertIs>(collector.awaitItem()) + assertEquals("seed", stale.value) + fetchStarted.await() + val ticket = assertIs(engine.state.value.fetch).ticket + releaseFetch.complete(Unit) + assertIs(ticket.outcome.await()) + + preStamp.release.complete(Unit) + writerCurrent.entered.await() + collector.expectNoEvents() + writerCurrent.release.complete(Unit) + + val committed = assertIs>(collector.awaitItem()) + assertEquals("candidate", committed.value) + assertEquals(Origin.FETCHER, committed.origin) + assertFalse(committed.refreshing) + assertEquals(1, calls) + observer.cancelAndIgnoreRemainingEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun delayedExactWriterRow_cannotRegressANewerSameValueRevalidation() = runTest { + val key = TestKey("delayed-exact-after-revalidation") + val sourceOfTruth = SingleWriterCurrentSourceOfTruth() + val mappingGate = SequencedGate() + val clock = FakeWallClock(now = 0L) + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("fresh") + 2 -> FetcherResult.NotModified(etag = "e2") + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = clock, + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = mappingGate::awaitIfQueued, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + val delayedExact = mappingGate.gateNext() + val first = async { engine.get(Freshness.MustBeFresh) } + delayedExact.entered.await() + sourceOfTruth.releaseWriteReturn.complete(Unit) + assertEquals("fresh", first.await()) + + clock.now = 10.minutes.inWholeMilliseconds + engine.invalidate() + assertEquals("fresh", engine.get(Freshness.MustBeFresh)) + assertEquals(2, calls) + + delayedExact.release.complete(Unit) + testScheduler.runCurrent() + assertEquals("fresh", engine.get(Freshness.MaxAge(notOlderThan = 5.minutes))) + assertEquals(2, calls, "delayed exact row must not restore the older metadata revision") + observer.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun delayedExactWriterRow_afterConfirmFresh_reusesCurrentEnvelopeForProjection() = runTest { + val key = TestKey("delayed-exact-after-confirm-fresh") + val sourceOfTruth = DelayedWriterEchoSourceOfTruth() + val exactRowMapped = CompletableDeferred() + var observeExactRow = false + var fetchCalls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + fetchCalls += 1 + FetcherResult.Success("unexpected") + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = { + if (observeExactRow) exactRowMapped.complete(Unit) + }, + overlay = PassThroughOverlay, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + engine.applyWrite("echo") + engine.confirmFresh(etag = "confirmed") + sourceOfTruth.writerEchoHeld.await() + + val delayed = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + observer.awaitItem() + } + observeExactRow = true + sourceOfTruth.releaseWriterEcho.complete(Unit) + exactRowMapped.await() + testScheduler.runCurrent() + + assertTrue( + delayed.isCompleted, + "the delayed exact row must authorize the metadata-refreshed projection", + ) + val echo = assertIs>(delayed.await()) + assertEquals("echo", echo.value) + assertEquals(Origin.SOT, echo.origin) + assertEquals("echo", engine.get(Freshness.MaxAge(notOlderThan = 5.minutes))) + assertEquals(0, fetchCalls) + observer.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun activeExactWriterRow_afterConfirmFresh_reusesCurrentEnvelopeForProjection() = runTest { + val key = TestKey("active-exact-after-confirm-fresh") + val sourceOfTruth = SingleWriterCurrentSourceOfTruth() + val mappingGate = SequencedGate() + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + error("write-handle acknowledgement must not fetch") + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = mappingGate::awaitIfQueued, + overlay = PassThroughOverlay, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + val activeExact = mappingGate.gateNext() + val apply = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.applyWrite("echo") + } + activeExact.entered.await() + sourceOfTruth.writerCurrentPublished.await() + + sourceOfTruth.releaseWriteReturn.complete(Unit) + apply.await() + engine.confirmFresh(etag = "confirmed") + testScheduler.runCurrent() + + val delayed = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + observer.awaitItem() + } + activeExact.release.complete(Unit) + testScheduler.runCurrent() + + assertTrue( + delayed.isCompleted, + "the active exact row must authorize the metadata-refreshed projection", + ) + val echo = assertIs>(delayed.await()) + assertEquals("echo", echo.value) + assertEquals(Origin.SOT, echo.origin) + assertFalse(echo.isStale) + assertFalse(echo.refreshing) + assertEquals("echo", engine.get(Freshness.MaxAge(notOlderThan = 5.minutes))) + observer.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun activeExactRowMappedBeforeConfirmFresh_overlayRetireRevealsConfirmedValue() = runTest { + val key = TestKey("active-exact-mapped-before-confirm") + val sourceOfTruth = SingleWriterCurrentSourceOfTruth() + val mappingGate = SequencedGate() + val readerDeliveryGate = SequencedGate() + val projectionDeliveryGate = SequencedGate() + val overlay = RetiringTestOverlay() + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + error("write-handle acknowledgement must not fetch") + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = mappingGate::awaitIfQueued, + beforeReaderDeliveryTestGate = readerDeliveryGate::awaitIfQueued, + overlay = overlay, + afterProjectionDeliveryTestGate = projectionDeliveryGate::awaitIfQueued, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + testScheduler.runCurrent() + val initial = assertIs>(observer.expectMostRecentItem()) + assertEquals("optimistic", initial.value) + assertEquals(Origin.OVERLAY, initial.origin) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + val activeExact = mappingGate.gateNext() + val apply = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.applyWrite("echo") + } + activeExact.entered.await() + sourceOfTruth.writerCurrentPublished.await() + sourceOfTruth.releaseWriteReturn.complete(Unit) + apply.await() + + val exactDelivery = readerDeliveryGate.gateNext() + activeExact.release.complete(Unit) + testScheduler.runCurrent() + assertTrue( + exactDelivery.entered.isCompleted, + "the exact R1 row must reach collector delivery before confirmFresh", + ) + exactDelivery.release.complete(Unit) + testScheduler.runCurrent() + observer.expectNoEvents() + + val confirmDelivery = projectionDeliveryGate.gateNext() + engine.confirmFresh(etag = "confirmed") + testScheduler.runCurrent() + assertTrue(confirmDelivery.entered.isCompleted) + observer.expectNoEvents() + confirmDelivery.release.complete(Unit) + testScheduler.runCurrent() + assertEquals("echo", engine.get(Freshness.MaxAge(notOlderThan = 5.minutes))) + + val retireDelivery = projectionDeliveryGate.gateNext() + overlay.retire(key) + testScheduler.runCurrent() + assertTrue(retireDelivery.entered.isCompleted, "retirement projection was processed") + + val confirmed = assertIs>(observer.expectMostRecentItem()) + assertEquals("echo", confirmed.value) + assertEquals(Origin.SOT, confirmed.origin) + assertFalse(confirmed.isStale) + assertFalse(confirmed.refreshing) + retireDelivery.release.complete(Unit) + observer.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun activeExactRowMappedAfterConfirmFresh_pendingOverlayRetireRevealsConfirmedValue() = runTest { + val key = TestKey("active-exact-mapped-after-confirm") + val sourceOfTruth = SingleWriterCurrentSourceOfTruth() + val mappingGate = SequencedGate() + val readerDeliveryGate = SequencedGate() + val projectionDeliveryGate = SequencedGate() + val overlay = RetiringTestOverlay() + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + error("write-handle acknowledgement must not fetch") + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = mappingGate::awaitIfQueued, + beforeReaderDeliveryTestGate = readerDeliveryGate::awaitIfQueued, + overlay = overlay, + afterProjectionDeliveryTestGate = projectionDeliveryGate::awaitIfQueued, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + testScheduler.runCurrent() + val initial = assertIs>(observer.expectMostRecentItem()) + assertEquals("optimistic", initial.value) + assertEquals(Origin.OVERLAY, initial.origin) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + val activeExact = mappingGate.gateNext() + val apply = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.applyWrite("echo") + } + activeExact.entered.await() + sourceOfTruth.writerCurrentPublished.await() + sourceOfTruth.releaseWriteReturn.complete(Unit) + apply.await() + + // This is the existing F-0 side of the cross-product: metadata advances first. + engine.confirmFresh(etag = "confirmed") + testScheduler.runCurrent() + observer.expectNoEvents() + + val exactDelivery = readerDeliveryGate.gateNext() + activeExact.release.complete(Unit) + testScheduler.runCurrent() + assertTrue( + exactDelivery.entered.isCompleted, + "the F-0 row must reach collector delivery after confirmFresh", + ) + exactDelivery.release.complete(Unit) + testScheduler.runCurrent() + observer.expectNoEvents() + + val retireDelivery = projectionDeliveryGate.gateNext() + overlay.retire(key) + testScheduler.runCurrent() + assertTrue(retireDelivery.entered.isCompleted, "retirement projection was processed") + val confirmed = assertIs>(observer.expectMostRecentItem()) + assertEquals("echo", confirmed.value) + assertEquals(Origin.SOT, confirmed.origin) + assertFalse(confirmed.isStale) + assertFalse(confirmed.refreshing) + retireDelivery.release.complete(Unit) + assertEquals("echo", engine.get(Freshness.MaxAge(notOlderThan = 5.minutes))) + observer.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun committedFence_withoutWriterEcho_restartsReaderForCurrentAuthority() = runTest { + val key = TestKey("committed-fence-without-writer-echo") + val sourceOfTruth = ConflatedWriterEchoSourceOfTruth() + val fencedRowMapped = CompletableDeferred() + var observeFencedRow = false + var fetchCalls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + fetchCalls += 1 + FetcherResult.Success("unexpected") + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = { + if (observeFencedRow) fencedRowMapped.complete(Unit) + }, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderHeld.await() + testScheduler.runCurrent() + val readerCallsBeforeRecovery = sourceOfTruth.readerCalls + + engine.applyWrite("candidate") + sourceOfTruth.publishExternal("external") + val delayed = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + observer.awaitItem() + } + observeFencedRow = true + sourceOfTruth.releaseLiveReader.complete(Unit) + fencedRowMapped.await() + testScheduler.runCurrent() + + assertTrue( + sourceOfTruth.readerCalls > readerCallsBeforeRecovery, + "a fenced mismatch must replace the reader before accepting current authority", + ) + assertTrue( + delayed.isCompleted, + "the replacement reader must deliver its causally post-return current row", + ) + val external = assertIs>(delayed.await()) + assertEquals("external", external.value) + assertEquals(Origin.SOT, external.origin) + assertEquals(0, fetchCalls) + observer.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun writerCurrentRaw_survivesGraceCancellationAfterMismatchedActiveRowMaps() = runTest { + val key = TestKey("writer-current-after-grace") + val sourceOfTruth = InterleavedWriteSourceOfTruth() + val mappingGate = SequencedGate() + val releaseFetch = CompletableDeferred() + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + releaseFetch.await() + FetcherResult.Success("fresh") + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = mappingGate::awaitIfQueued, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + val mismatchedRow = mappingGate.gateNext() + val writerCurrentRow = mappingGate.gateNext() + val owner = + engine.stream(Freshness.MaxAge(notOlderThan = 5.minutes)) + .testIn(backgroundScope) + assertIs(owner.awaitItem()) + releaseFetch.complete(Unit) + + mismatchedRow.entered.await() + val ticket = checkNotNull(engine.state.value.attribution).owner + mismatchedRow.release.complete(Unit) + val mismatched = assertIs>(observer.awaitItem()) + assertEquals("mismatched", mismatched.value) + assertEquals(Origin.SOT, mismatched.origin) + + sourceOfTruth.releaseWriterCurrent.complete(Unit) + writerCurrentRow.entered.await() + sourceOfTruth.writerCurrentPublished.await() + owner.cancelAndIgnoreRemainingEvents() + observer.cancelAndIgnoreRemainingEvents() + testScheduler.advanceTimeBy(READER_PIPELINE_GRACE_MILLIS + 1L) + testScheduler.runCurrent() + + sourceOfTruth.releaseWriteReturn.complete(Unit) + ticket.disposition.first { it is FetchDisposition.Committed } + assertEquals("fresh", engine.get(Freshness.LocalOnly)) + writerCurrentRow.release.complete(Unit) + } + } + + @Test + fun matchingThenOtherThenFinalMatching_convergesOnlyTheFinalWriterRow() = runTest { + val key = TestKey("matching-other-final-matching") + val sourceOfTruth = MatchingOtherMatchingSourceOfTruth() + val mappingGate = SequencedGate() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + calls += 1 + FetcherResult.Success("fresh") + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = mappingGate::awaitIfQueued, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + val firstMatching = mappingGate.gateNext() + val owner = + engine.stream(Freshness.MaxAge(notOlderThan = 5.minutes)) + .testIn(backgroundScope) + assertIs(owner.awaitItem()) + firstMatching.entered.await() + sourceOfTruth.finalMatchingPublished.await() + val ticket = checkNotNull(engine.state.value.attribution).owner + assertEquals("seed", engine.get(Freshness.LocalOnly)) + + sourceOfTruth.releaseWriteReturn.complete(Unit) + ticket.disposition.first { it is FetchDisposition.Committed } + assertEquals("fresh", engine.get(Freshness.LocalOnly)) + firstMatching.release.complete(Unit) + + val committed = assertIs>(owner.awaitItem()) + assertEquals("fresh", committed.value) + assertEquals(Origin.FETCHER, committed.origin) + val observed = assertIs>(observer.awaitItem()) + assertEquals("fresh", observed.value) + assertEquals(Origin.FETCHER, observed.origin) + owner.expectNoEvents() + observer.expectNoEvents() + assertEquals(1, calls) + owner.cancelAndIgnoreRemainingEvents() + observer.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun postCutoffExternalRow_winsWhileTheCommitOutcomeTailIsStillPending() = runTest { + val key = TestKey("post-cutoff-external-row") + val sourceOfTruth = SingleWriterCurrentSourceOfTruth() + val bookkeeper = GateSuccessBookkeeper() + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { FetcherResult.Success("fresh") }, + sot = sourceOfTruth, + bookkeeper = bookkeeper, + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + bookkeeper.gateNextSuccess() + + val owner = async { engine.get(Freshness.CachedOrFetch) } + sourceOfTruth.writerCurrentPublished.await() + sourceOfTruth.releaseWriteReturn.complete(Unit) + bookkeeper.successEntered.await() + + sourceOfTruth.publishExternal("external") + var external: StoreResult.Data? = null + while (external == null) { + val item = observer.awaitItem() + if (item is StoreResult.Data && item.value == "external") external = item + } + assertEquals(Origin.SOT, checkNotNull(external).origin) + assertEquals("external", engine.get(Freshness.LocalOnly)) + + bookkeeper.releaseSuccess.complete(Unit) + owner.await() + observer.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun clearReaderGeneration_dropsAGatedOlderRawRow() = runTest { + val key = TestKey("clear-drops-old-raw") + val sourceOfTruth = StartupRaceSourceOfTruth() + val mappingGate = SequencedGate() + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { FetcherResult.Success("unused") }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = mappingGate::awaitIfQueued, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + val oldRow = mappingGate.gateNext() + sourceOfTruth.publish("old") + oldRow.entered.await() + engine.clear() + oldRow.release.complete(Unit) + testScheduler.runCurrent() + assertTrue(runCatching { engine.get(Freshness.LocalOnly) }.isFailure) + + sourceOfTruth.publish("new") + var delivered: StoreResult.Data? = null + while (delivered == null) { + val item = observer.awaitItem() + if (item is StoreResult.Data) { + assertEquals("new", item.value) + delivered = item + } + } + observer.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun failedActiveWriterRow_isDroppedAndReaderPipelineRecovers() = runTest { + val key = TestKey("failed-active-writer-row") + val sourceOfTruth = FailingWriterSourceOfTruth() + val mappingGate = SequencedGate() + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { FetcherResult.Success("fresh") }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = mappingGate::awaitIfQueued, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + val writerCurrentRow = mappingGate.gateNext() + val owner = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + assertEquals("seed", assertIs>(owner.awaitItem()).value) + writerCurrentRow.entered.await() + val ticket = checkNotNull(engine.state.value.attribution).owner + writerCurrentRow.release.complete(Unit) + sourceOfTruth.releaseFailure.complete(Unit) + ticket.disposition.first { it == FetchDisposition.Failed } + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + observer.expectNoEvents() + sourceOfTruth.publishExternal("external") + val external = assertIs>(observer.awaitItem()) + assertEquals("external", external.value) + assertEquals(Origin.SOT, external.origin) + owner.cancelAndIgnoreRemainingEvents() + observer.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun failedWrite_postMatchExternalRow_resumesAsSotAfterTerminalCleanup() = runTest { + val key = TestKey("failed-write-post-match-external") + val sourceOfTruth = FailingAfterPostMatchExternalSourceOfTruth() + val mappingGate = SequencedGate() + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { FetcherResult.Success("fresh") }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = mappingGate::awaitIfQueued, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + val matchingRow = mappingGate.gateNext() + val postMatchExternalRow = mappingGate.gateNext() + val owner = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + assertEquals("seed", assertIs>(owner.awaitItem()).value) + matchingRow.entered.await() + val ticket = checkNotNull(engine.state.value.attribution).owner + + sourceOfTruth.releaseExternal.complete(Unit) + sourceOfTruth.externalPublished.await() + matchingRow.release.complete(Unit) + postMatchExternalRow.entered.await() + postMatchExternalRow.release.complete(Unit) + testScheduler.runCurrent() + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + observer.expectNoEvents() + + sourceOfTruth.releaseFailure.complete(Unit) + ticket.disposition.first { it == FetchDisposition.Failed } + val external = assertIs>(observer.awaitItem()) + assertEquals("external", external.value) + assertEquals(Origin.SOT, external.origin) + assertEquals("external", engine.get(Freshness.LocalOnly)) + owner.cancelAndIgnoreRemainingEvents() + observer.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun activeWriterCurrentRow_restoresProvenanceOnlyAfterDurableReturn() = runTest { + val key = TestKey("active-writer-current-row") + val sourceOfTruth = InterleavedWriteSourceOfTruth() + val mappingGate = SequencedGate() + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> { + fetchStarted.complete(Unit) + releaseFetch.await() + FetcherResult.Success("fresh") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = mappingGate::awaitIfQueued, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + val mismatchedRow = mappingGate.gateNext() + val writerCurrentRow = mappingGate.gateNext() + val collector = + engine.stream(Freshness.MaxAge(notOlderThan = 5.minutes)) + .testIn(backgroundScope) + + assertIs(collector.awaitItem()) + fetchStarted.await() + releaseFetch.complete(Unit) + + mismatchedRow.entered.await() + mismatchedRow.release.complete(Unit) + testScheduler.runCurrent() + assertEquals(null, engine.state.value.attribution) + collector.expectNoEvents() + val mismatched = assertIs>(observer.awaitItem()) + assertEquals("mismatched", mismatched.value) + assertEquals(Origin.SOT, mismatched.origin) + + sourceOfTruth.releaseWriterCurrent.complete(Unit) + writerCurrentRow.entered.await() + sourceOfTruth.writerCurrentPublished.await() + writerCurrentRow.release.complete(Unit) + testScheduler.runCurrent() + collector.expectNoEvents() + assertEquals("mismatched", engine.get(Freshness.LocalOnly)) + observer.expectNoEvents() + + val passive = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + val heldBaseline = assertIs>(passive.awaitItem()) + assertEquals("mismatched", heldBaseline.value) + passive.expectNoEvents() + + sourceOfTruth.releaseWriteReturn.complete(Unit) + val committed = assertIs>(collector.awaitItem()) + assertEquals("fresh", committed.value) + assertEquals(Origin.FETCHER, committed.origin) + assertFalse(committed.isStale) + assertFalse(committed.refreshing) + assertEquals("fresh", assertIs>(observer.awaitItem()).value) + assertEquals("fresh", assertIs>(passive.awaitItem()).value) + assertEquals("fresh", engine.get(Freshness.LocalOnly)) + assertEquals(1, calls) + observer.cancelAndIgnoreRemainingEvents() + passive.cancelAndIgnoreRemainingEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun matchingWriterCurrentRow_staysOutsideResidenceUntilDurableReturn() = runTest { + val key = TestKey("matching-writer-current-row") + val sourceOfTruth = SingleWriterCurrentSourceOfTruth() + val mappingGate = SequencedGate() + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + fetchStarted.complete(Unit) + releaseFetch.await() + FetcherResult.Success("fresh") + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = mappingGate::awaitIfQueued, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + val writerCurrentRow = mappingGate.gateNext() + val collector = + engine.stream(Freshness.MaxAge(notOlderThan = 5.minutes)) + .testIn(backgroundScope) + assertIs(collector.awaitItem()) + fetchStarted.await() + releaseFetch.complete(Unit) + + writerCurrentRow.entered.await() + sourceOfTruth.writerCurrentPublished.await() + writerCurrentRow.release.complete(Unit) + testScheduler.runCurrent() + assertEquals(null, engine.state.value.attribution) + assertEquals("seed", engine.get(Freshness.LocalOnly)) + observer.expectNoEvents() + collector.expectNoEvents() + + val passive = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(passive.awaitItem()).value) + passive.expectNoEvents() + + sourceOfTruth.releaseWriteReturn.complete(Unit) + assertEquals("fresh", assertIs>(collector.awaitItem()).value) + assertEquals("fresh", assertIs>(observer.awaitItem()).value) + assertEquals("fresh", assertIs>(passive.awaitItem()).value) + assertEquals("fresh", engine.get(Freshness.LocalOnly)) + observer.cancelAndIgnoreRemainingEvents() + passive.cancelAndIgnoreRemainingEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun cancelledActiveWriterRow_isDroppedAndReaderPipelineRecovers() = runTest { + val key = TestKey("cancelled-active-writer-row") + val sourceOfTruth = CancellingWriterSourceOfTruth() + val mappingGate = SequencedGate() + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { FetcherResult.Success("fresh") }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = mappingGate::awaitIfQueued, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + val writerCurrentRow = mappingGate.gateNext() + val owner = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + assertEquals("seed", assertIs>(owner.awaitItem()).value) + writerCurrentRow.entered.await() + sourceOfTruth.writerCurrentPublished.await() + val ticket = checkNotNull(engine.state.value.attribution).owner + writerCurrentRow.release.complete(Unit) + testScheduler.runCurrent() + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + observer.expectNoEvents() + sourceOfTruth.releaseCancellation.complete(Unit) + ticket.disposition.first { it == FetchDisposition.Cancelled } + testScheduler.runCurrent() + assertEquals("seed", engine.get(Freshness.LocalOnly)) + observer.expectNoEvents() + owner.cancelAndIgnoreRemainingEvents() + + sourceOfTruth.publishExternal("external") + val external = assertIs>(observer.awaitItem()) + assertEquals("external", external.value) + assertEquals(Origin.SOT, external.origin) + observer.cancelAndIgnoreRemainingEvents() + } + } + + private class InitialDeliveryGate { + private var armed = false + val entered = CompletableDeferred() + private val released = CompletableDeferred() + + fun arm() { + armed = true + } + + suspend fun awaitIfArmed() { + if (!armed) return + armed = false + entered.complete(Unit) + released.await() + } + + fun release() { + released.complete(Unit) + } + } + + private class SequencedGate { + private val queued = ArrayDeque() + + fun gateNext(): Step = Step().also(queued::addLast) + + suspend fun awaitIfQueued() { + val step = queued.removeFirstOrNull() ?: return + step.entered.complete(Unit) + step.release.await() + } + + class Step { + val entered = CompletableDeferred() + val release = CompletableDeferred() + } + } + + private data class ColdPathContractObservation( + val origin: Origin, + val isStale: Boolean, + val refreshing: Boolean, + val hasMeta: Boolean, + val fetchCalls: Int, + ) + + private class HeldColdObservationSourceOfTruth( + private val immediateReaderCalls: Int = 1, + ) : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow(null) + private var readerCalls = 0 + val coldObservationHeld = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + if (readerCalls <= immediateReaderCalls) return rows + return flow { + var first = true + var heldColdObservation = false + rows.collect { value -> + if (first && value == null) { + first = false + heldColdObservation = true + coldObservationHeld.complete(Unit) + delay(COLD_OBSERVATION_HOLD) + } else if (heldColdObservation) { + heldColdObservation = false + delay(WRITER_CURRENT_DELIVERY_GAP) + } + emit(value) + } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.value = value + } + + override suspend fun delete(key: TestKey) { + rows.value = null + } + } + + private class ConsecutiveWriteEchoSourceOfTruth : SingleRowTestSourceOfTruth { + private val liveRows = MutableSharedFlow(extraBufferCapacity = 2) + private var readerCalls = 0 + private var current: String? = null + val liveReaderStarted = CompletableDeferred() + val replacementReaderStarted = CompletableDeferred() + val firstEchoHeld = CompletableDeferred() + val releaseFirstEcho = CompletableDeferred() + val secondWriteStarted = CompletableDeferred() + val releaseSecondWriteReturn = CompletableDeferred() + val releaseSecondEcho = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + val readerCall = ++readerCalls + val isLiveReader = readerCall >= 2 + return flow { + emit(current) + if (!isLiveReader) return@flow + liveReaderStarted.complete(Unit) + if (readerCall >= 3) replacementReaderStarted.complete(Unit) + liveRows.collect { value -> + when (value) { + "first" -> { + firstEchoHeld.complete(Unit) + releaseFirstEcho.await() + } + + "second" -> releaseSecondEcho.await() + } + emit(value) + } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + current = value + check(liveRows.tryEmit(value)) + if (value == "second") { + secondWriteStarted.complete(Unit) + releaseSecondWriteReturn.await() + } + } + + override suspend fun delete(key: TestKey) { + current = null + check(liveRows.tryEmit(null)) + } + + fun publishExternal(value: String) { + current = value + check(liveRows.tryEmit(value)) + } + } + + private class FailingSuccessorSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow(null) + private var readerCalls = 0 + val liveReaderStarted = CompletableDeferred() + val firstNotificationQueued = CompletableDeferred() + val releaseFirstNotification = CompletableDeferred() + val secondRawCaptured = CompletableDeferred() + val releaseRollback = CompletableDeferred() + val rollbackRawCaptured = CompletableDeferred() + val releaseFailure = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + val readerCall = ++readerCalls + if (readerCall == 1) return flow { emit(rows.value) } + return flow { + emit(rows.value) + liveReaderStarted.complete(Unit) + emitAll( + rows.drop(1).transformLatest { value -> + if (value == "first" && !firstNotificationQueued.isCompleted) { + firstNotificationQueued.complete(Unit) + releaseFirstNotification.await() + } else { + emit(value) + when (value) { + "second" -> secondRawCaptured.complete(Unit) + "first" -> rollbackRawCaptured.complete(Unit) + } + } + }, + ) + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.value = value + if (value == "first") { + firstNotificationQueued.await() + return + } + + check(value == "second") + secondRawCaptured.await() + releaseRollback.await() + rows.value = "first" + rollbackRawCaptured.await() + releaseFailure.await() + throw IllegalStateException("successor write failed") + } + + override suspend fun delete(key: TestKey) { + rows.value = null + } + } + + private class ReplayEveryRowSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableSharedFlow(replay = 1) + private var readerCalls = 0 + val liveReaderStarted = CompletableDeferred() + + init { + rows.tryEmit("seed") + } + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + if (readerCalls >= 2) liveReaderStarted.complete(Unit) + return rows + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.emit(value) + } + + override suspend fun delete(key: TestKey) { + rows.emit(null) + } + + suspend fun publish(value: String) { + rows.emit(value) + } + } + + private class InterleavedWriteSourceOfTruth : SingleRowTestSourceOfTruth { + private val liveRows = MutableSharedFlow() + private var readerCalls = 0 + private var current: String? = "seed" + val liveReaderStarted = CompletableDeferred() + val writerCurrentPublished = CompletableDeferred() + val releaseWriterCurrent = CompletableDeferred() + val releaseWriteReturn = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + val isLiveReader = readerCalls >= 2 + return flow { + if (isLiveReader) liveReaderStarted.complete(Unit) + emit(current) + if (isLiveReader) liveRows.collect { emit(it) } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + current = "mismatched" + liveRows.emit("mismatched") + releaseWriterCurrent.await() + current = value + liveRows.emit(value) + writerCurrentPublished.complete(Unit) + releaseWriteReturn.await() + } + + override suspend fun delete(key: TestKey) { + current = null + liveRows.emit(null) + } + } + + private class SingleWriterCurrentSourceOfTruth : SingleRowTestSourceOfTruth { + private val liveRows = MutableSharedFlow() + private var readerCalls = 0 + private var current: String? = "seed" + val liveReaderStarted = CompletableDeferred() + val writerCurrentPublished = CompletableDeferred() + val releaseWriteReturn = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + val isLiveReader = readerCalls >= 2 + return flow { + if (isLiveReader) liveReaderStarted.complete(Unit) + emit(current) + if (isLiveReader) liveRows.collect { emit(it) } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + current = value + liveRows.emit(value) + writerCurrentPublished.complete(Unit) + releaseWriteReturn.await() + } + + override suspend fun delete(key: TestKey) { + current = null + liveRows.emit(null) + } + + suspend fun publishExternal(value: String) { + current = value + liveRows.emit(value) + } + } + + private class DelayedWriterEchoSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = + MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ).also { check(it.tryEmit("seed")) } + var readerCalls: Int = 0 + private set + val liveReaderStarted = CompletableDeferred() + val writerEchoHeld = CompletableDeferred() + val releaseWriterEcho = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + val isLiveReader = readerCalls >= 2 + return flow { + rows.collect { value -> + if (isLiveReader && value == "echo" && !writerEchoHeld.isCompleted) { + writerEchoHeld.complete(Unit) + releaseWriterEcho.await() + } + emit(value) + if (isLiveReader) liveReaderStarted.complete(Unit) + } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.emit(value) + } + + override suspend fun delete(key: TestKey) { + rows.emit(null) + } + } + + private class ConflatedWriterEchoSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = + MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ).also { check(it.tryEmit("seed")) } + var readerCalls: Int = 0 + private set + val liveReaderHeld = CompletableDeferred() + val releaseLiveReader = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + val isLiveReader = readerCalls >= 2 + return flow { + rows.collect { value -> + emit(value) + if (isLiveReader && !liveReaderHeld.isCompleted) { + liveReaderHeld.complete(Unit) + releaseLiveReader.await() + } + } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.emit(value) + } + + override suspend fun delete(key: TestKey) { + rows.emit(null) + } + + suspend fun publishExternal(value: String) { + rows.emit(value) + } + } + + private object PassThroughOverlay : Overlay { + override fun apply( + key: TestKey, + base: String?, + ): String? = base + + override val changes: Flow = emptyFlow() + } + + private class RetiringTestOverlay : Overlay { + private val signals = MutableSharedFlow(replay = 1) + private var pending = true + + override fun apply( + key: TestKey, + base: String?, + ): String? = if (pending) "optimistic" else base + + override val changes: Flow = signals + + suspend fun retire(key: TestKey) { + pending = false + signals.emit(key) + } + } + + private class CancellingWriterSourceOfTruth : SingleRowTestSourceOfTruth { + private val liveRows = MutableSharedFlow() + private var readerCalls = 0 + private var current: String? = "seed" + val liveReaderStarted = CompletableDeferred() + val writerCurrentPublished = CompletableDeferred() + val releaseCancellation = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + val isLiveReader = readerCalls >= 2 + return flow { + if (isLiveReader) liveReaderStarted.complete(Unit) + emit(current) + if (isLiveReader) liveRows.collect { emit(it) } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + val previous = current + current = value + liveRows.emit(value) + writerCurrentPublished.complete(Unit) + releaseCancellation.await() + current = previous + liveRows.emit(previous) + throw kotlinx.coroutines.CancellationException("cancelled write") + } + + override suspend fun delete(key: TestKey) { + current = null + liveRows.emit(null) + } + + suspend fun publishExternal(value: String) { + current = value + liveRows.emit(value) + } + } + + private class MatchingOtherMatchingSourceOfTruth : SingleRowTestSourceOfTruth { + private val liveRows = MutableSharedFlow() + private var readerCalls = 0 + private var current: String? = "seed" + val liveReaderStarted = CompletableDeferred() + val finalMatchingPublished = CompletableDeferred() + val releaseWriteReturn = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + val isLiveReader = readerCalls >= 2 + return flow { + if (isLiveReader) liveReaderStarted.complete(Unit) + emit(current) + if (isLiveReader) liveRows.collect { emit(it) } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + current = value + liveRows.emit(value) + current = "intermediate" + liveRows.emit("intermediate") + current = value + liveRows.emit(value) + finalMatchingPublished.complete(Unit) + releaseWriteReturn.await() + } + + override suspend fun delete(key: TestKey) { + current = null + liveRows.emit(null) + } + } + + private class FailingWriterSourceOfTruth : SingleRowTestSourceOfTruth { + private val liveRows = MutableSharedFlow() + private var readerCalls = 0 + private var current: String? = "seed" + val liveReaderStarted = CompletableDeferred() + val releaseFailure = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + val isLiveReader = readerCalls >= 2 + return flow { + if (isLiveReader) liveReaderStarted.complete(Unit) + emit(current) + if (isLiveReader) liveRows.collect { emit(it) } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + val previous = current + current = value + liveRows.emit(value) + releaseFailure.await() + current = previous + liveRows.emit(previous) + throw IllegalStateException("write failed") + } + + override suspend fun delete(key: TestKey) { + current = null + liveRows.emit(null) + } + + suspend fun publishExternal(value: String) { + current = value + liveRows.emit(value) + } + } + + private class FailingAfterPostMatchExternalSourceOfTruth : SingleRowTestSourceOfTruth { + private val liveRows = MutableSharedFlow() + private var readerCalls = 0 + private var current: String? = "seed" + val liveReaderStarted = CompletableDeferred() + val releaseExternal = CompletableDeferred() + val externalPublished = CompletableDeferred() + val releaseFailure = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + val isLiveReader = readerCalls >= 2 + return flow { + if (isLiveReader) liveReaderStarted.complete(Unit) + emit(current) + if (isLiveReader) liveRows.collect { emit(it) } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + current = value + liveRows.emit(value) + releaseExternal.await() + current = "external" + liveRows.emit(current) + externalPublished.complete(Unit) + releaseFailure.await() + throw IllegalStateException("write failed") + } + + override suspend fun delete(key: TestKey) { + current = null + liveRows.emit(null) + } + } + + private class StartupRaceSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow("seed") + private var readerCalls = 0 + val liveReaderStarted = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + val call = readerCalls + return flow { + if (call >= 2) liveReaderStarted.complete(Unit) + rows.collect { emit(it) } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.value = value + } + + override suspend fun delete(key: TestKey) { + rows.value = null + } + + fun publish(value: String) { + rows.value = value + } + + fun publishAbsent() { + rows.value = null + } + } + + private class WriteStartedSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow(null) + val writeStarted = CompletableDeferred() + + override fun reader(key: TestKey): Flow = rows + + override suspend fun write( + key: TestKey, + value: String, + ) { + writeStarted.complete(Unit) + rows.value = value + } + + override suspend fun delete(key: TestKey) { + rows.value = null + } + } + + private class GatedWriterReturnSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow("seed") + val writeStarted = CompletableDeferred() + val releaseWrite = CompletableDeferred() + + override fun reader(key: TestKey): Flow = rows + + override suspend fun write( + key: TestKey, + value: String, + ) { + writeStarted.complete(Unit) + releaseWrite.await() + rows.value = value + } + + override suspend fun delete(key: TestKey) { + rows.value = null + } + } + + private class FailureSignallingBookkeeper : Bookkeeper { + private val delegate = InMemoryBookkeeper() + val failureRecorded = CompletableDeferred() + + override suspend fun recordSuccess( + key: StoreKey, + meta: StoreMeta, + ) { + delegate.recordSuccess(key, meta) + } + + override suspend fun recordFailure( + key: StoreKey, + atEpochMillis: Long, + ) { + delegate.recordFailure(key, atEpochMillis) + failureRecorded.complete(Unit) + } + + override suspend fun status(key: StoreKey): KeyStatus? = delegate.status(key) + + override suspend fun forget(key: StoreKey) { + delegate.forget(key) + } + + override suspend fun markStale(key: StoreKey) = delegate.markStale(key) + + override suspend fun advanceStaleWatermark(namespace: StoreNamespace) = + delegate.advanceStaleWatermark(namespace) + + override suspend fun advanceGlobalStaleWatermark() = + delegate.advanceGlobalStaleWatermark() + + override suspend fun forgetNamespace(namespace: StoreNamespace) = + delegate.forgetNamespace(namespace) + + override suspend fun forgetAll() = delegate.forgetAll() + } + + private class GateSuccessBookkeeper : Bookkeeper { + private val delegate = InMemoryBookkeeper() + private var gateNext = false + val successEntered = CompletableDeferred() + val releaseSuccess = CompletableDeferred() + val failureRecorded = CompletableDeferred() + + fun gateNextSuccess() { + gateNext = true + } + + override suspend fun recordSuccess( + key: StoreKey, + meta: StoreMeta, + ) { + delegate.recordSuccess(key, meta) + if (gateNext) { + gateNext = false + successEntered.complete(Unit) + releaseSuccess.await() + } + } + + override suspend fun recordFailure( + key: StoreKey, + atEpochMillis: Long, + ) { + delegate.recordFailure(key, atEpochMillis) + failureRecorded.complete(Unit) + } + + override suspend fun status(key: StoreKey): KeyStatus? = delegate.status(key) + + override suspend fun forget(key: StoreKey) { + delegate.forget(key) + } + + override suspend fun markStale(key: StoreKey) = delegate.markStale(key) + + override suspend fun advanceStaleWatermark(namespace: StoreNamespace) = + delegate.advanceStaleWatermark(namespace) + + override suspend fun advanceGlobalStaleWatermark() = + delegate.advanceGlobalStaleWatermark() + + override suspend fun forgetNamespace(namespace: StoreNamespace) = + delegate.forgetNamespace(namespace) + + override suspend fun forgetAll() = delegate.forgetAll() + } + + /** Pauses the selected success before it can clear durable staleness in the delegate. */ + private class PreDelegateSuccessGateBookkeeper : Bookkeeper { + private val delegate = InMemoryBookkeeper() + private var gateNext = false + val successEntered = CompletableDeferred() + val releaseSuccess = CompletableDeferred() + + fun gateNextSuccess() { + gateNext = true + } + + override suspend fun recordSuccess( + key: StoreKey, + meta: StoreMeta, + ) { + if (gateNext) { + gateNext = false + successEntered.complete(Unit) + releaseSuccess.await() + } + delegate.recordSuccess(key, meta) + } + + override suspend fun recordFailure( + key: StoreKey, + atEpochMillis: Long, + ) = delegate.recordFailure(key, atEpochMillis) + + override suspend fun status(key: StoreKey): KeyStatus? = delegate.status(key) + + override suspend fun forget(key: StoreKey) = delegate.forget(key) + + override suspend fun markStale(key: StoreKey) = delegate.markStale(key) + + override suspend fun advanceStaleWatermark(namespace: StoreNamespace) = + delegate.advanceStaleWatermark(namespace) + + override suspend fun advanceGlobalStaleWatermark() = + delegate.advanceGlobalStaleWatermark() + + override suspend fun forgetNamespace(namespace: StoreNamespace) = + delegate.forgetNamespace(namespace) + + override suspend fun forgetAll() = delegate.forgetAll() + } + + /** Pauses one selected status call after it has captured the delegate's immutable snapshot. */ + private class PostDelegateStatusGateBookkeeper : Bookkeeper { + private val delegate = InMemoryBookkeeper() + private val statusCallsUntilGate = MutableStateFlow(0) + val statusEntered = CompletableDeferred() + val releaseStatus = CompletableDeferred() + + fun gateStatusCall(number: Int) { + require(number > 0) { "number must be positive" } + check(statusCallsUntilGate.compareAndSet(expect = 0, update = number)) { + "a status gate is already armed" + } + } + + override suspend fun recordSuccess( + key: StoreKey, + meta: StoreMeta, + ) = delegate.recordSuccess(key, meta) + + override suspend fun recordFailure( + key: StoreKey, + atEpochMillis: Long, + ) = delegate.recordFailure(key, atEpochMillis) + + override suspend fun status(key: StoreKey): KeyStatus? { + val captured = delegate.status(key) + while (true) { + val remaining = statusCallsUntilGate.value + if (remaining == 0) return captured + if (statusCallsUntilGate.compareAndSet(remaining, remaining - 1)) { + if (remaining == 1) { + statusEntered.complete(Unit) + releaseStatus.await() + } + return captured + } + } + } + + override suspend fun forget(key: StoreKey) = delegate.forget(key) + + override suspend fun markStale(key: StoreKey) = delegate.markStale(key) + + override suspend fun advanceStaleWatermark(namespace: StoreNamespace) = + delegate.advanceStaleWatermark(namespace) + + override suspend fun advanceGlobalStaleWatermark() = + delegate.advanceGlobalStaleWatermark() + + override suspend fun forgetNamespace(namespace: StoreNamespace) = + delegate.forgetNamespace(namespace) + + override suspend fun forgetAll() = delegate.forgetAll() + } + + private class GateFailureBookkeeper : Bookkeeper { + private val delegate = InMemoryBookkeeper() + private var gateNext = false + val failureEntered = CompletableDeferred() + val releaseFailure = CompletableDeferred() + + fun gateNextFailure() { + gateNext = true + } + + override suspend fun recordSuccess( + key: StoreKey, + meta: StoreMeta, + ) { + delegate.recordSuccess(key, meta) + } + + override suspend fun recordFailure( + key: StoreKey, + atEpochMillis: Long, + ) { + delegate.recordFailure(key, atEpochMillis) + if (gateNext) { + gateNext = false + failureEntered.complete(Unit) + releaseFailure.await() + } + } + + override suspend fun status(key: StoreKey): KeyStatus? = delegate.status(key) + + override suspend fun forget(key: StoreKey) { + delegate.forget(key) + } + + override suspend fun markStale(key: StoreKey) = delegate.markStale(key) + + override suspend fun advanceStaleWatermark(namespace: StoreNamespace) = + delegate.advanceStaleWatermark(namespace) + + override suspend fun advanceGlobalStaleWatermark() = + delegate.advanceGlobalStaleWatermark() + + override suspend fun forgetNamespace(namespace: StoreNamespace) = + delegate.forgetNamespace(namespace) + + override suspend fun forgetAll() = delegate.forgetAll() + } + + private class GateForgetBookkeeper : Bookkeeper { + private val delegate = InMemoryBookkeeper() + private var gateNext = false + val forgetEntered = CompletableDeferred() + val releaseForget = CompletableDeferred() + + fun gateNextForget() { + gateNext = true + } + + override suspend fun recordSuccess( + key: StoreKey, + meta: StoreMeta, + ) { + delegate.recordSuccess(key, meta) + } + + override suspend fun recordFailure( + key: StoreKey, + atEpochMillis: Long, + ) { + delegate.recordFailure(key, atEpochMillis) + } + + override suspend fun status(key: StoreKey): KeyStatus? = delegate.status(key) + + override suspend fun forget(key: StoreKey) { + delegate.forget(key) + if (gateNext) { + gateNext = false + forgetEntered.complete(Unit) + releaseForget.await() + } + } + + override suspend fun markStale(key: StoreKey) = delegate.markStale(key) + + override suspend fun advanceStaleWatermark(namespace: StoreNamespace) = + delegate.advanceStaleWatermark(namespace) + + override suspend fun advanceGlobalStaleWatermark() = + delegate.advanceGlobalStaleWatermark() + + override suspend fun forgetNamespace(namespace: StoreNamespace) = + delegate.forgetNamespace(namespace) + + override suspend fun forgetAll() = delegate.forgetAll() + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/KeyRegistryTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/KeyRegistryTest.kt new file mode 100644 index 000000000..a2761f7b5 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/KeyRegistryTest.kt @@ -0,0 +1,393 @@ +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.FakeWallClock +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.TestKey +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import org.mobilenativefoundation.store6.core.store +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class, ExperimentalCoroutinesApi::class) +class KeyRegistryTest { + @Test + fun withEngine_createsOnce_reusesResident() = runTest { + val factory = TrackingEngineFactory(backgroundScope) + val registry = registry(maxIdle = 1, factory) + val observed = mutableListOf>() + + registry.withEngine(TestKey("a")) { engine -> observed += engine } + registry.withEngine(TestKey("a")) { engine -> observed += engine } + + assertEquals(1, factory.created.size) + assertSame(observed[0], observed[1]) + assertEquals(1L, registry.createdCountForTest()) + } + + @Test + fun release_atZeroRefs_parksQuiescentEngineInIdle() = runTest { + val factory = TrackingEngineFactory(backgroundScope) + val registry = registry(maxIdle = 1, factory) + + registry.withEngine(TestKey("a")) { + assertEquals(1, registry.residentCountForTest()) + assertEquals(0, registry.idleCountForTest()) + } + + assertEquals(1, registry.residentCountForTest()) + assertEquals(1, registry.idleCountForTest()) + assertFalse(factory.only("a").job.isCancelled) + } + + @Test + fun idleOverflow_evictsEldestOnly() = runTest { + val factory = TrackingEngineFactory(backgroundScope) + val registry = registry(maxIdle = 2, factory) + + registry.withEngine(TestKey("a")) {} + registry.withEngine(TestKey("b")) {} + registry.withEngine(TestKey("c")) {} + + assertTrue(factory.only("a").job.isCancelled) + assertFalse(factory.only("b").job.isCancelled) + assertFalse(factory.only("c").job.isCancelled) + assertEquals(2, registry.residentCountForTest()) + assertEquals(2, registry.idleCountForTest()) + assertEquals(1L, registry.destroyedCountForTest()) + } + + @Test + fun reacquire_promotesFromIdle_andRefreshesLruPosition() = runTest { + val factory = TrackingEngineFactory(backgroundScope) + val registry = registry(maxIdle = 2, factory) + var firstA: KeyEngine? = null + + registry.withEngine(TestKey("a")) { engine -> firstA = engine } + registry.withEngine(TestKey("b")) {} + registry.withEngine(TestKey("a")) { engine -> assertSame(firstA, engine) } + registry.withEngine(TestKey("c")) {} + + assertFalse(factory.only("a").job.isCancelled) + assertTrue(factory.only("b").job.isCancelled) + assertFalse(factory.only("c").job.isCancelled) + assertEquals(1, factory.forId("a").size) + } + + @Test + fun maxIdleZero_destroysAtQuiescence() = runTest { + val factory = TrackingEngineFactory(backgroundScope) + val registry = registry(maxIdle = 0, factory) + + registry.withEngine(TestKey("a")) {} + + assertTrue(factory.only("a").job.isCancelled) + assertEquals(0, registry.residentCountForTest()) + assertEquals(0, registry.idleCountForTest()) + assertEquals(1L, registry.createdCountForTest()) + assertEquals(1L, registry.destroyedCountForTest()) + } + + @Test + fun fetchResidencyHook_pinsEngineAcrossCallerRelease() = runTest { + val factory = TrackingEngineFactory(backgroundScope) + val registry = registry(maxIdle = 0, factory) + val callerEntered = CompletableDeferred() + val releaseCaller = CompletableDeferred() + val caller = + async(start = CoroutineStart.UNDISPATCHED) { + registry.withEngine(TestKey("a")) { + callerEntered.complete(Unit) + releaseCaller.await() + } + } + callerEntered.await() + val created = factory.only("a") + + created.hooks.retainFetchRef() + releaseCaller.complete(Unit) + caller.await() + + assertEquals(1, registry.residentCountForTest()) + assertEquals(0L, registry.destroyedCountForTest()) + assertFalse(created.job.isCancelled) + + created.hooks.releaseFetchRef() + assertEquals(0, registry.residentCountForTest()) + assertEquals(1L, registry.destroyedCountForTest()) + assertTrue(created.job.isCancelled) + } + + @Test + fun closedRegistry_withEngine_throwsStoreClosed() = runTest { + val factory = TrackingEngineFactory(backgroundScope) + val registry = registry(maxIdle = 1, factory) + registry.clearOnClose() + + val failure = + assertFailsWith { + registry.withEngine(TestKey("a")) {} + } + + assertEquals(STORE_CLOSED_MESSAGE, failure.message) + assertEquals(0, factory.created.size) + } + + @Test + fun sweep_retainsEngines_andReleasesAfterAction() = runTest { + val factory = TrackingEngineFactory(backgroundScope) + val registry = registry(maxIdle = 0, factory) + val releaseHolders = CompletableDeferred() + val heldA = CompletableDeferred>() + val heldB = CompletableDeferred>() + val holderA = + async(start = CoroutineStart.UNDISPATCHED) { + registry.withEngine(TestKey("a")) { engine -> + heldA.complete(engine) + releaseHolders.await() + } + } + val holderB = + async(start = CoroutineStart.UNDISPATCHED) { + registry.withEngine(TestKey("b")) { engine -> + heldB.complete(engine) + releaseHolders.await() + } + } + val expected = setOf(heldA.await(), heldB.await()) + val actedOn = mutableSetOf>() + val actionEntered = CompletableDeferred() + val releaseAction = CompletableDeferred() + + val sweep = + async(start = CoroutineStart.UNDISPATCHED) { + registry.snapshotAndForEachResident(namespace = null) { engine -> + actedOn += engine + actionEntered.complete(Unit) + releaseAction.await() + } + } + actionEntered.await() + releaseHolders.complete(Unit) + holderA.await() + holderB.await() + + assertEquals(2, registry.residentCountForTest()) + assertEquals(0, registry.idleCountForTest()) + assertEquals(0L, registry.destroyedCountForTest()) + assertFalse(factory.only("a").job.isCancelled) + assertFalse(factory.only("b").job.isCancelled) + + releaseAction.complete(Unit) + val snapshot = sweep.await() + assertEquals(expected, actedOn) + assertEquals(expected, snapshot.toSet()) + assertEquals(0, registry.residentCountForTest()) + assertEquals(0, registry.idleCountForTest()) + assertEquals(2L, registry.destroyedCountForTest()) + assertTrue(factory.only("a").job.isCancelled) + assertTrue(factory.only("b").job.isCancelled) + } + + @Test + fun sweep_releasesAllRetainedEngines_whenActionThrows() = runTest { + val factory = TrackingEngineFactory(backgroundScope) + val registry = registry(maxIdle = 0, factory) + val releaseHolders = CompletableDeferred() + val heldA = CompletableDeferred() + val heldB = CompletableDeferred() + val holderA = + async(start = CoroutineStart.UNDISPATCHED) { + registry.withEngine(TestKey("a")) { + heldA.complete(Unit) + releaseHolders.await() + } + } + val holderB = + async(start = CoroutineStart.UNDISPATCHED) { + registry.withEngine(TestKey("b")) { + heldB.complete(Unit) + releaseHolders.await() + } + } + heldA.await() + heldB.await() + + val failure = + assertFailsWith { + registry.snapshotAndForEachResident(namespace = null) { + throw IllegalStateException("sweep failed") + } + } + assertEquals("sweep failed", failure.message) + assertEquals(2, registry.residentCountForTest()) + assertEquals(0L, registry.destroyedCountForTest()) + + releaseHolders.complete(Unit) + holderA.await() + holderB.await() + assertEquals(0, registry.residentCountForTest()) + assertEquals(2L, registry.destroyedCountForTest()) + assertTrue(factory.only("a").job.isCancelled) + assertTrue(factory.only("b").job.isCancelled) + } + + @Test + fun fetchJob_pinsResidencyAfterLastWaiterCancels_untilCommitSettles() = runTest { + val key = TestKey("fetch-residency") + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + var fetchCalls = 0 + val real = + store { + maxIdleKeys(0) + fetcher { + fetchCalls += 1 + fetchStarted.complete(Unit) + releaseFetch.await() + "committed" + } + } as RealStore + val waiterEngine = CompletableDeferred>() + val firstWaiter = + async(start = CoroutineStart.UNDISPATCHED) { + real.withEngine(key) { engine -> + waiterEngine.complete(engine) + engine.get(Freshness.MustBeFresh) + } + } + try { + fetchStarted.await() + val engine = waiterEngine.await() + assertTrue(engine.state.value.fetch is FetchSlot.InFlight) + firstWaiter.cancelAndJoin() + assertEquals(1, fetchCalls) + assertEquals(1, real.residentEngineCountForTest()) + assertEquals(0, real.idleEngineCountForTest()) + assertEquals(1L, real.createdEngineCountForTest()) + assertEquals(0L, real.destroyedEngineCountForTest()) + + val fetchSettled = + async(start = CoroutineStart.UNDISPATCHED) { + engine.state.first { state -> state.fetch is FetchSlot.Idle } + } + releaseFetch.complete(Unit) + fetchSettled.await() + awaitRegistryCounts(real, resident = 0, destroyed = 1L) + + assertEquals("committed", real.get(key, Freshness.CachedOrFetch)) + assertEquals(1, fetchCalls) + awaitRegistryCounts(real, resident = 0, destroyed = 2L) + assertEquals( + real.residentEngineCountForTest().toLong(), + real.createdEngineCountForTest() - real.destroyedEngineCountForTest(), + ) + } finally { + releaseFetch.complete(Unit) + firstWaiter.cancelAndJoin() + real.close() + real.awaitTerminationForTest() + } + } + + @Test + fun counters_createdMinusDestroyed_equalsResident() = runTest { + val factory = TrackingEngineFactory(backgroundScope) + val registry = registry(maxIdle = 2, factory) + + repeat(5) { index -> registry.withEngine(TestKey("key-$index")) {} } + + assertEquals(5L, registry.createdCountForTest()) + assertEquals(3L, registry.destroyedCountForTest()) + assertEquals( + registry.residentCountForTest().toLong(), + registry.createdCountForTest() - registry.destroyedCountForTest(), + ) + } + + private fun registry( + maxIdle: Int, + factory: TrackingEngineFactory, + ): KeyRegistry = KeyRegistry(maxIdle, factory::create) + + private suspend fun awaitRegistryCounts( + store: RealStore, + resident: Int, + destroyed: Long, + ) { + // Preserve the real-time Default-dispatch hop and let the suite-level runTest bound own + // cancellation. + withContext(Dispatchers.Default) { + while ( + store.residentEngineCountForTest() != resident || + store.destroyedEngineCountForTest() != destroyed + ) { + yield() + } + } + } + + private class TrackingEngineFactory( + private val parentScope: CoroutineScope, + ) { + val created = mutableListOf() + + fun create( + key: TestKey, + id: KeyId, + hooks: EngineResidencyHooks, + ): KeyEngine { + val job = SupervisorJob(parentScope.coroutineContext[Job]) + val engine = + KeyEngine( + key = key, + keyId = id, + fetcher = ResultFetcher { FetcherResult.Success("unused") }, + sot = InMemorySourceOfTruth(), + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = CoroutineScope(parentScope.coroutineContext + job), + residencyHooks = hooks, + ) + created += CreatedEngine(id.canonicalId, engine, job, hooks) + return engine + } + + fun forId(canonicalId: String): List = + created.filter { it.canonicalId == canonicalId } + + fun only(canonicalId: String): CreatedEngine = forId(canonicalId).single() + } + + private data class CreatedEngine( + val canonicalId: String, + val engine: KeyEngine, + val job: Job, + val hooks: EngineResidencyHooks, + ) +} + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = 25.seconds, testBody = testBody) diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/MaintenanceCoordinatorTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/MaintenanceCoordinatorTest.kt new file mode 100644 index 000000000..4ce892009 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/MaintenanceCoordinatorTest.kt @@ -0,0 +1,321 @@ +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.fail + +class MaintenanceCoordinatorTest { + @Test + fun commitsInUnrelatedNamespacesOverlap() = runTest { + val coordinator = MaintenanceCoordinator() + val alphaStarted = CompletableDeferred() + val releaseAlpha = CompletableDeferred() + val betaStarted = CompletableDeferred() + + val alpha = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + coordinator.withCommit("alpha") { + alphaStarted.complete(Unit) + releaseAlpha.await() + "alpha" + } + } + alphaStarted.await() + + val beta = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + coordinator.withCommit("beta") { + betaStarted.complete(Unit) + "beta" + } + } + + betaStarted.await() + assertEquals("beta", beta.await()) + assertFalse(alpha.isCompleted) + + releaseAlpha.complete(Unit) + assertEquals("alpha", alpha.await()) + } + + @Test + fun namespaceMaintenanceDrainsAndBlocksOnlyItsNamespace() = runTest { + val coordinator = MaintenanceCoordinator() + val activeStarted = CompletableDeferred() + val releaseActive = CompletableDeferred() + val maintenanceStarted = CompletableDeferred() + val releaseMaintenance = CompletableDeferred() + val blockedCommitStarted = CompletableDeferred() + + val active = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + coordinator.withCommit("alpha") { + activeStarted.complete(Unit) + releaseActive.await() + } + } + activeStarted.await() + + val maintenance = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + coordinator.withNamespaceMaintenance("alpha") { + maintenanceStarted.complete(Unit) + releaseMaintenance.await() + "maintained" + } + } + val blockedCommit = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + coordinator.withCommit("alpha") { + blockedCommitStarted.complete(Unit) + "alpha-after" + } + } + val otherCommit = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + coordinator.withCommit("beta") { "beta" } + } + + assertFalse(maintenanceStarted.isCompleted) + assertFalse(blockedCommitStarted.isCompleted) + assertEquals("beta", otherCommit.await()) + + releaseActive.complete(Unit) + active.await() + maintenanceStarted.await() + assertFalse(blockedCommitStarted.isCompleted) + + releaseMaintenance.complete(Unit) + assertEquals("maintained", maintenance.await()) + assertEquals("alpha-after", blockedCommit.await()) + } + + @Test + fun globalMaintenanceDrainsAndBlocksEveryNamespace() = runTest { + val coordinator = MaintenanceCoordinator() + val alphaStarted = CompletableDeferred() + val betaStarted = CompletableDeferred() + val releaseAlpha = CompletableDeferred() + val releaseBeta = CompletableDeferred() + val maintenanceStarted = CompletableDeferred() + val releaseMaintenance = CompletableDeferred() + val laterAlphaStarted = CompletableDeferred() + val laterBetaStarted = CompletableDeferred() + + val alpha = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + coordinator.withCommit("alpha") { + alphaStarted.complete(Unit) + releaseAlpha.await() + } + } + val beta = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + coordinator.withCommit("beta") { + betaStarted.complete(Unit) + releaseBeta.await() + } + } + alphaStarted.await() + betaStarted.await() + + val maintenance = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + coordinator.withGlobalMaintenance { + maintenanceStarted.complete(Unit) + releaseMaintenance.await() + } + } + val laterAlpha = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + coordinator.withCommit("alpha") { + laterAlphaStarted.complete(Unit) + } + } + val laterBeta = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + coordinator.withCommit("beta") { + laterBetaStarted.complete(Unit) + } + } + + assertFalse(laterAlphaStarted.isCompleted) + assertFalse(laterBetaStarted.isCompleted) + releaseAlpha.complete(Unit) + alpha.await() + assertFalse(maintenanceStarted.isCompleted) + + releaseBeta.complete(Unit) + beta.await() + maintenanceStarted.await() + assertFalse(laterAlphaStarted.isCompleted) + assertFalse(laterBetaStarted.isCompleted) + + releaseMaintenance.complete(Unit) + maintenance.await() + laterAlpha.await() + laterBeta.await() + } + + @Test + fun cancelledCommitBodyReleasesItsLease() = runTest { + val coordinator = MaintenanceCoordinator() + val commitStarted = CompletableDeferred() + + val commit = + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + coordinator.withCommit("alpha") { + commitStarted.complete(Unit) + awaitCancellation() + } + } + commitStarted.await() + commit.cancelAndJoin() + + assertEquals( + "maintained", + coordinator.withNamespaceMaintenance("alpha") { "maintained" }, + ) + } + + @Test + fun cancelledDrainingMaintenanceRemovesBlockedScope() = runTest { + val coordinator = MaintenanceCoordinator() + val activeStarted = CompletableDeferred() + val releaseActive = CompletableDeferred() + val maintenanceBodyStarted = CompletableDeferred() + val laterCommitStarted = CompletableDeferred() + val releaseLaterCommit = CompletableDeferred() + + val active = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + coordinator.withCommit("alpha") { + activeStarted.complete(Unit) + releaseActive.await() + } + } + activeStarted.await() + + val maintenance = + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + coordinator.withNamespaceMaintenance("alpha") { + maintenanceBodyStarted.complete(Unit) + } + } + assertFalse(maintenanceBodyStarted.isCompleted) + maintenance.cancelAndJoin() + + val laterCommit = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + coordinator.withCommit("alpha") { + laterCommitStarted.complete(Unit) + releaseLaterCommit.await() + } + } + laterCommitStarted.await() + assertFalse(active.isCompleted) + + releaseLaterCommit.complete(Unit) + laterCommit.await() + releaseActive.complete(Unit) + active.await() + } + + @Test + fun commitCallbackCannotEnterSameNamespaceMaintenance() = runTest { + val coordinator = MaintenanceCoordinator() + + assertReentryFailsFast { + coordinator.withCommit("alpha") { + coordinator.withNamespaceMaintenance("alpha") {} + } + } + } + + @Test + fun commitCallbackCannotEnterGlobalMaintenance() = runTest { + val coordinator = MaintenanceCoordinator() + + assertReentryFailsFast { + coordinator.withCommit("alpha") { + coordinator.withGlobalMaintenance {} + } + } + } + + @Test + fun maintenanceCallbackCannotEnterMaintenance() = runTest { + val coordinator = MaintenanceCoordinator() + + assertReentryFailsFast { + coordinator.withNamespaceMaintenance("alpha") { + coordinator.withGlobalMaintenance {} + } + } + } + + @Test + fun maintenanceCallbackCannotEnterSameScopeCommit() = runTest { + val coordinator = MaintenanceCoordinator() + + assertReentryFailsFast { + coordinator.withNamespaceMaintenance("alpha") { + coordinator.withCommit("alpha") {} + } + } + } + + @Test + fun nestedCoordinatorChainCannotReenterEarlierCoordinator() = runTest { + val first = MaintenanceCoordinator() + val second = MaintenanceCoordinator() + + assertReentryFailsFast { + first.withCommit("alpha") { + second.withCommit("beta") { + first.withCommit("gamma") {} + } + } + } + } + + @Test + fun callbackMayEnterIndependentCoordinator() = runTest { + val first = MaintenanceCoordinator() + val second = MaintenanceCoordinator() + + assertEquals( + "independent", + first.withCommit("alpha") { + second.withNamespaceMaintenance("alpha") { "independent" } + }, + ) + } + + private suspend fun TestScope.assertReentryFailsFast(block: suspend () -> Unit) { + val attempt = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { block() } + } + if (!attempt.isCompleted) { + attempt.cancelAndJoin() + fail("Re-entry suspended instead of failing fast") + } + + val failure = assertIs(attempt.await().exceptionOrNull()) + assertEquals( + "MaintenanceCoordinator callbacks cannot re-enter the same coordinator.", + failure.message, + ) + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/OverlayProjectionProtocolTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/OverlayProjectionProtocolTest.kt new file mode 100644 index 000000000..8ae0fefc0 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/OverlayProjectionProtocolTest.kt @@ -0,0 +1,1951 @@ +package org.mobilenativefoundation.store6.core.internal + +import app.cash.turbine.ReceiveTurbine +import app.cash.turbine.test +import app.cash.turbine.testIn +import app.cash.turbine.turbineScope +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.updateAndGet +import kotlinx.coroutines.selects.select +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import kotlinx.coroutines.withContext +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.TestKey +import org.mobilenativefoundation.store6.core.store +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import org.mobilenativefoundation.store6.core.seam.Overlay +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import org.mobilenativefoundation.store6.core.seam.StoreTelemetry +import org.mobilenativefoundation.store6.core.seam.runtime +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +/** Deterministic proofs for the private base/revision/generation projection protocol. */ +@OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) +class OverlayProjectionProtocolTest { + private val key = TestKey("overlay-protocol") + + @Test + fun projectionVocabulary_keepsExactThreeVariants() { + val envelope = + ValueEnvelope( + value = "v", + origin = Origin.SOT, + meta = null, + staleEpochAtCommit = 0L, + ) + + assertSame(envelope, assertIs>(Projection.Value(envelope)).envelope) + assertEquals("optimistic", assertIs>(Projection.Overlaid("optimistic")).value) + assertSame(Projection.Absent, Projection.Absent) + } + + @Test + fun staleV1Apply_isDiscardedAfterResidenceAdvancesToV2() = runTest { + val overlay = CountingOverlay({ base -> base?.plus("+projected") }) + val staleGate = SuspendGate() + var blockV1 = true + val harness = + stringEngine( + overlay = overlay, + fetcher = { FetcherResult.Success("v1") }, + afterProjectionApplyTestGate = { base -> + if (base == "v1" && blockV1) { + blockV1 = false + staleGate.pause() + } + }, + ) + + try { + harness.engine.stream(Freshness.CachedOrFetch).test { + assertIs(awaitItem()) + staleGate.awaitEntered() + + val write = async(Dispatchers.Default) { harness.engine.applyWrite("v2") } + awaitFromDefault { write.await() } + staleGate.release() + + val data = awaitDataValue("v2+projected") + assertEquals(Origin.OVERLAY, data.origin) + assertFalse(seenDataValues.contains("v1+projected")) + cancelAndIgnoreRemainingEvents() + } + } finally { + staleGate.release() + harness.close() + } + } + + @Test + fun sameEnvelopeAtNewRevision_cannotConsumeOldReadiness() = runTest { + val value = RefValue("same", "base") + val source = SharedFlowSourceOfTruth() + val projectionNumber = MutableStateFlow(0) + val completedProjections = Channel(Channel.UNLIMITED) + val overlay = CountingOverlay({ base -> + base?.let { + val projected = + RefValue( + id = it.id, + tag = "projection-${projectionNumber.updateAndGet { number -> number + 1 }}", + ) + check(completedProjections.trySend(projected).isSuccess) + projected + } + }) + val staleGate = SuspendGate() + val readerGate = SuspendGate() + val blockProjection = MutableStateFlow(false) + val blockReader = MutableStateFlow(false) + val harness = + refEngine( + overlay = overlay, + sot = source, + fetcher = { FetcherResult.Success(value) }, + afterProjectionApplyTestGate = { base -> + if (base === value && blockProjection.compareAndSet(true, false)) { + staleGate.pause() + } + }, + beforeReaderDeliveryTestGate = { + if (blockReader.compareAndSet(true, false)) { + readerGate.pause() + } + }, + ) + + try { + harness.engine.stream(Freshness.CachedOrFetch).test { + awaitRefData() + overlay.clearCalls() + while (completedProjections.tryReceive().isSuccess) Unit + blockProjection.value = true + overlay.signals.emit(key) + staleGate.awaitEntered() + assertSame(value, overlay.awaitCall()) + val rejectedTag = completedProjections.receive().tag + + blockReader.value = true + source.write(key, value) + readerGate.awaitEntered() // mapping and its revision bump already happened + staleGate.release() + + val latestCall = overlay.awaitCall() + assertSame(value, latestCall) + val acceptedTag = completedProjections.receive().tag + readerGate.release() + val latest = awaitDataTag(acceptedTag) + assertEquals(Origin.OVERLAY, latest.origin) + assertFalse(seenDataTags.contains(rejectedTag)) + cancelAndIgnoreRemainingEvents() + } + } finally { + staleGate.release() + readerGate.release() + harness.close() + } + } + + @Test + fun queuedReady_cannotRenderAfterResidenceAdvances() = runTest { + val staleDelivery = SuspendGate() + val staleDelivered = SuspendGate() + val initialReader = SuspendGate() + val newerReader = SuspendGate() + val source = ContractSourceOfTruth() + var blockStaleDelivery = false + var watchStaleDelivered = false + var blockInitialReader = true + var blockNewerReader = false + var suffix = "initial" + val overlay = CountingOverlay({ base -> base?.plus("+$suffix") }) + val harness = + stringEngine( + overlay = overlay, + sot = source, + fetcher = { awaitCancellation() }, + beforeReaderDeliveryLockTestGate = { record -> + if ( + blockNewerReader && + record is ReaderRecord.Row && + record.envelope.value == "v2" + ) { + blockNewerReader = false + newerReader.pause() + } + }, + beforeReaderDeliveryTestGate = { + if (blockInitialReader) { + blockInitialReader = false + initialReader.pause() + } + }, + beforeProjectionDeliveryLockTestGate = { + if (blockStaleDelivery) { + blockStaleDelivery = false + staleDelivery.pause() + } + }, + afterProjectionDeliveryTestGate = { + if (watchStaleDelivered) { + watchStaleDelivered = false + staleDelivered.pause() + } + }, + ) + + try { + harness.engine.applyWrite("v1") + harness.engine.stream(Freshness.LocalOnly).test { + awaitDataValue("v1+initial") + source.readerStarted.await() + initialReader.awaitEntered() + + suffix = "barrier" + overlay.clearCalls() + overlay.signals.emit(key) + assertEquals("v1", overlay.awaitCall()) + initialReader.release() + initialReader.awaitExited() + awaitDataValue("v1+barrier") + + suffix = "stale" + blockStaleDelivery = true + watchStaleDelivered = true + overlay.clearCalls() + overlay.signals.emit(key) + assertEquals("v1", overlay.awaitCall()) + staleDelivery.awaitEntered() + + suffix = "latest" + blockNewerReader = true + harness.engine.applyWrite("v2") + newerReader.awaitEntered() + while (overlay.awaitCall() != "v2") Unit + + staleDelivery.release() + staleDelivered.awaitEntered() + staleDelivered.release() + newerReader.release() + awaitDataValue("v2+latest") + assertFalse(seenDataValues.contains("v1+stale")) + cancelAndIgnoreRemainingEvents() + } + } finally { + staleDelivery.release() + staleDelivered.release() + initialReader.release() + newerReader.release() + harness.close() + } + } + + @Test + fun queuedOlderReady_cannotReplayAfterFailureFlushesLatestGeneration() = runTest { + val fetch = SuspendGate() + val oldDelivery = SuspendGate() + val boom = IllegalStateException("offline") + var calls = 0 + var suffix = "initial" + var blockOldDelivery = false + val overlay = CountingOverlay({ base -> base?.plus("+$suffix") }) + val harness = + stringEngine( + overlay = overlay, + fetcher = { + when (++calls) { + 1 -> FetcherResult.Success("v") + 2 -> { + fetch.pause() + FetcherResult.Error(boom) + } + else -> error("unexpected fetch $calls") + } + }, + beforeProjectionDeliveryLockTestGate = { + if (blockOldDelivery) { + blockOldDelivery = false + oldDelivery.pause() + } + }, + ) + + try { + harness.engine.stream(Freshness.StaleIfError).test { + awaitDataValue("v+initial") + harness.engine.invalidate() + fetch.awaitEntered() + + suffix = "old" + blockOldDelivery = true + overlay.clearCalls() + overlay.signals.emit(key) + assertEquals("v", overlay.awaitCall()) + oldDelivery.awaitEntered() + + suffix = "latest" + overlay.signals.emit(key) + assertEquals("v", overlay.awaitCall()) + fetch.release() + + assertEquals("v+latest", awaitDataValue().value) + assertIs(assertIs(awaitItem()).error) + + oldDelivery.release() + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + fetch.release() + oldDelivery.release() + harness.close() + } + } + + @Test + fun failureFlush_replansWhenAuthorizedProjectionObsoletes() = runTest { + val fetch = SuspendGate() + val oldProjection = SuspendGate() + val readerMapping = SuspendGate() + val refreshProjectionDelivery = SuspendGate() + val outcomeDelivery = SuspendGate() + val firstReadiness = SuspendGate() + val secondReadiness = SuspendGate() + val boom = IllegalStateException("offline") + var suffix = "initial" + var blockOldProjection = false + var deliverRefreshProjection = false + var watchReadiness = false + var readinessCalls = 0 + val overlay = CountingOverlay({ base -> base?.plus("+$suffix") }) + val source = ContractSourceOfTruth() + val harness = + stringEngine( + overlay = overlay, + sot = source, + fetcher = { + fetch.pause() + FetcherResult.Error(boom) + }, + beforeReaderRecordMappingTestGate = { readerMapping.pause() }, + afterProjectionApplyTestGate = { base -> + if (base == "v1" && blockOldProjection) { + blockOldProjection = false + oldProjection.pause() + } + }, + afterProjectionDeliveryTestGate = { + if (deliverRefreshProjection) { + deliverRefreshProjection = false + refreshProjectionDelivery.pause() + } + }, + beforeTicketOutcomeDeliveryTestGate = { outcomeDelivery.pause() }, + beforeProjectionReadinessWaitTestGate = { + if (watchReadiness) { + when (++readinessCalls) { + 1 -> firstReadiness.pause() + 2 -> secondReadiness.pause() + } + } + }, + ) + + try { + harness.engine.applyWrite("v1") + overlay.awaitCallValue("v1") + harness.engine.stream(Freshness.StaleIfError).test { + awaitDataValue("v1+initial") + source.readerStarted.await() + readerMapping.awaitEntered() + harness.engine.invalidate() + fetch.awaitEntered() + + suffix = "refreshing" + deliverRefreshProjection = true + overlay.clearCalls() + overlay.signals.emit(key) + assertEquals("v1", overlay.awaitCall()) + refreshProjectionDelivery.awaitEntered() + refreshProjectionDelivery.release() + refreshProjectionDelivery.awaitExited() + awaitDataValue("v1+refreshing") + + suffix = "latest" + blockOldProjection = true + overlay.clearCalls() + overlay.signals.emit(key) + assertEquals("v1", overlay.awaitCall()) + oldProjection.awaitEntered() + + fetch.release() + outcomeDelivery.awaitEntered() + watchReadiness = true + outcomeDelivery.release() + outcomeDelivery.awaitExited() + firstReadiness.awaitEntered() + + val write = async(Dispatchers.Default) { harness.engine.applyWrite("v2") } + awaitFromDefault { write.await() } + val nextItem = async { awaitItem() } + val secondWait = async(Dispatchers.Default) { secondReadiness.awaitEntered() } + firstReadiness.release() + + val winner = + select { + secondWait.onAwait { "replanned" } + nextItem.onAwait { "public-item" } + } + assertEquals("replanned", winner) + + oldProjection.release() + assertEquals("v2", overlay.awaitCall()) + secondReadiness.release() + val projected = assertIs>(nextItem.await()) + assertEquals("v2+latest", projected.value) + assertEquals(Origin.OVERLAY, projected.origin) + assertIs(assertIs(awaitItem()).error) + cancelAndIgnoreRemainingEvents() + } + } finally { + fetch.release() + oldProjection.release() + readerMapping.release() + refreshProjectionDelivery.release() + outcomeDelivery.release() + firstReadiness.release() + secondReadiness.release() + harness.close() + } + } + + @Test + fun pendingOldBase_obsoletesWaiterAndQueuedWriteCompletes() = runTest { + val overlay = CountingOverlay({ base -> base?.plus("+op") }) + val pendingGate = SuspendGate() + var blockNull = true + val harness = + stringEngine( + overlay = overlay, + fetcher = { awaitCancellation() }, + beforeProjectionApplyTestGate = { base -> + if (base == null && blockNull) { + blockNull = false + pendingGate.pause() + } + }, + ) + + try { + harness.engine.stream(Freshness.LocalOnly).test { + pendingGate.awaitEntered() + val write = async(Dispatchers.Default) { harness.engine.applyWrite("confirmed") } + awaitFromDefault { write.await() } + pendingGate.release() + + val data = awaitDataValue("confirmed+op") + assertEquals(Origin.OVERLAY, data.origin) + + overlay.transform = { it } + overlay.signals.emit(key) + val retired = awaitDataValue("confirmed") + assertTrue(retired.origin == Origin.SOT || retired.origin == Origin.MEMORY) + cancelAndIgnoreRemainingEvents() + } + } finally { + pendingGate.release() + harness.close() + } + } + + @Test + fun foreignDirectOwner_sameReferenceBaselineRemainsLive() = runTest { + val resident = RefValue("same", "base") + val secondFetch = SuspendGate() + val outcomeGate = SuspendGate() + var calls = 0 + val overlay = CountingOverlay({ it }) + val harness = + refEngine( + overlay = overlay, + fetcher = { + when (++calls) { + 1 -> FetcherResult.Success(resident, etag = "e1") + 2 -> { + secondFetch.pause() + FetcherResult.NotModified("e2") + } + else -> error("unexpected fetch $calls") + } + }, + beforeTicketOutcomeDeliveryTestGate = { + if (calls == 2) outcomeGate.pause() + }, + ) + + try { + harness.engine.stream(Freshness.CachedOrFetch).test { + awaitDataTag("base") + harness.engine.invalidate() + secondFetch.awaitEntered() + secondFetch.release() + outcomeGate.awaitEntered() // refreshed direct-owner envelope is now residence + + overlay.transform = { base -> base?.let { RefValue(it.id, "same-ref-overlay") } } + overlay.signals.emit(key) + val projected = awaitDataTag("same-ref-overlay") + assertEquals(Origin.OVERLAY, projected.origin) + outcomeGate.release() + cancelAndIgnoreRemainingEvents() + } + } finally { + secondFetch.release() + outcomeGate.release() + harness.close() + } + } + + @Test + fun obsoleteForeignWait_restoresPreviousVisibleAuthorization() = runTest { + val visible = RefValue("visible", "base") + val interloper = RefValue("interloper", "base") + val settleMarker = RefValue("visible", "settle-marker") + val projected = RefValue("visible", "foreign-overlay") + val source = ContractSourceOfTruth() + val interloperApply = SuspendGate() + val interloperReadiness = SuspendGate() + val initialVisibleReader = SuspendGate() + val visibleEchoMapping = SuspendGate() + val outcomeDelivery = SuspendGate() + val projectionDelivered = SuspendGate() + val telemetry = RecordingTelemetry() + var blockInterloperApply = false + var blockInterloperReadiness = false + var blockInitialVisibleReader = true + var blockVisibleEchoMapping = false + var watchProjectionDelivery = false + val overlay = CountingOverlay({ it }) + val harness = + refEngine( + overlay = overlay, + sot = source, + fetcher = { FetcherResult.NotModified("fresh") }, + beforeReaderRecordMappingTestGate = { + if (blockVisibleEchoMapping) { + blockVisibleEchoMapping = false + visibleEchoMapping.pause() + } + }, + afterProjectionApplyTestGate = { base -> + if (base === interloper && blockInterloperApply) { + blockInterloperApply = false + interloperApply.pause() + } + }, + beforeProjectionReadinessWaitTestGate = { + if (blockInterloperReadiness) { + blockInterloperReadiness = false + interloperReadiness.pause() + } + }, + beforeReaderDeliveryTestGate = { + if (blockInitialVisibleReader) { + blockInitialVisibleReader = false + initialVisibleReader.pause() + } + }, + beforeTicketOutcomeDeliveryTestGate = { outcomeDelivery.pause() }, + afterProjectionDeliveryTestGate = { + if ( + watchProjectionDelivery && + telemetry.serves.lastOrNull() == Origin.OVERLAY + ) { + watchProjectionDelivery = false + projectionDelivered.pause() + } + }, + telemetry = telemetry, + ) + + try { + harness.engine.applyWrite(visible) + overlay.awaitCallValue(visible) + harness.engine.stream(Freshness.LocalOnly).test { + assertSame(visible, awaitRefData().value) + source.readerStarted.await() + initialVisibleReader.awaitEntered() + overlay.transform = { base -> if (base === visible) settleMarker else base } + overlay.signals.emit(key) + initialVisibleReader.release() + initialVisibleReader.awaitExited() + assertSame(settleMarker, awaitDataTag("settle-marker").value) + telemetry.awaitServe(Origin.OVERLAY) + telemetry.clear() + + overlay.transform = { it } + overlay.signals.emit(key) + assertSame(visible, awaitDataTag("base").value) + telemetry.awaitServe() + telemetry.clear() + + blockInterloperApply = true + blockInterloperReadiness = true + source.write(key, interloper) + interloperApply.awaitEntered() + interloperReadiness.awaitEntered() + + blockVisibleEchoMapping = true + harness.engine.applyWrite(visible) + val fresh = async(Dispatchers.Default) { harness.engine.get(Freshness.MustBeFresh) } + outcomeDelivery.awaitEntered() + + overlay.transform = { base -> if (base === visible) projected else base } + telemetry.clear() + interloperReadiness.release() + visibleEchoMapping.awaitEntered() + + watchProjectionDelivery = true + interloperApply.release() + overlay.awaitCallValue(visible) + projectionDelivered.awaitEntered() + + assertEquals(listOf(Origin.OVERLAY), telemetry.serves) + projectionDelivered.release() + assertSame(projected, awaitRefData().value) + + outcomeDelivery.release() + assertSame(visible, awaitFromDefault { fresh.await() }) + cancelAndIgnoreRemainingEvents() + } + } finally { + interloperApply.release() + interloperReadiness.release() + initialVisibleReader.release() + visibleEchoMapping.release() + outcomeDelivery.release() + projectionDelivered.release() + harness.close() + } + } + + @Test + fun foreignDirectOwner_equalButDistinctReaderFirstReusesExactAuthorizedBaseline() = + runForeignDirectOwnerEqualDistinctOrdering(ForeignOwnerOrdering.READER_FIRST) + + @Test + fun foreignDirectOwner_equalButDistinctOwnerFirstNeverReusesOlderBaseline() = + runForeignDirectOwnerEqualDistinctOrdering(ForeignOwnerOrdering.OWNER_FIRST) + + private fun runForeignDirectOwnerEqualDistinctOrdering(ordering: ForeignOwnerOrdering) = runTest { + val older = RefValue("equal", "base") + val newer = RefValue("equal", "base") + val mustNotLeak = RefValue("equal", "must-not-leak") + assertEquals(older, newer) + assertTrue(older !== newer) + val fetchGate = SuspendGate() + val outcomeGate = SuspendGate() + val preWriteMapping = SuspendGate() + val orderingMapping = SuspendGate() + val trailingMapping = SuspendGate() + val initialObserverProjection = SuspendGate() + val writerProjection = SuspendGate() + val ownerProjection = SuspendGate() + val targetProjection = SuspendGate() + val projectionAfterGates = Channel(Channel.UNLIMITED) + val telemetry = RecordingTelemetry() + val source = ContractSourceOfTruth() + var calls = 0 + var readerMappingCalls = 0 + val overlay = CountingOverlay({ it }) + val harness = + refEngine( + overlay = overlay, + sot = source, + fetcher = { + when (++calls) { + 1 -> { + fetchGate.pause() + FetcherResult.NotModified("e2") + } + else -> error("unexpected fetch $calls") + } + }, + beforeTicketOutcomeDeliveryTestGate = { + if (calls == 1) outcomeGate.pause() + }, + beforeReaderRecordMappingTestGate = { + when (++readerMappingCalls) { + 1 -> { + preWriteMapping.pause() + orderingMapping.pause() + } + else -> trailingMapping.pause() + } + }, + afterProjectionDeliveryTestGate = { + projectionAfterGates.tryReceive().getOrNull()?.pause() + }, + telemetry = telemetry, + ) + + try { + harness.engine.applyWrite(older) + overlay.awaitCallValue(older) + overlay.clearCalls() + check(projectionAfterGates.trySend(initialObserverProjection).isSuccess) + harness.engine.stream(Freshness.CachedOrFetch).test { + val initial = awaitRefData() + assertSame(older, initial.value) + assertEquals(Origin.SOT, initial.origin) + source.readerStarted.await() + preWriteMapping.awaitEntered() + initialObserverProjection.awaitEntered() + initialObserverProjection.release() + initialObserverProjection.awaitExited() + telemetry.clear() + overlay.clearCalls() + + check(projectionAfterGates.trySend(writerProjection).isSuccess) + harness.engine.applyWrite(newer) + assertSame(newer, overlay.awaitCall()) + writerProjection.awaitEntered() + writerProjection.release() + writerProjection.awaitExited() + overlay.clearCalls() + + preWriteMapping.release() + preWriteMapping.awaitExited() + orderingMapping.awaitEntered() + if (ordering == ForeignOwnerOrdering.READER_FIRST) { + orderingMapping.release() + orderingMapping.awaitExited() + trailingMapping.awaitEntered() + trailingMapping.release() + trailingMapping.awaitExited() + val current = awaitRefData() + assertSame(newer, current.value) + assertEquals(Origin.SOT, current.origin) + assertEquals(Origin.SOT, telemetry.awaitServe(Origin.SOT)) + } + + val foreignOwner = async(Dispatchers.Default) { + harness.engine.get(Freshness.MustBeFresh) + } + fetchGate.awaitEntered() + check(projectionAfterGates.trySend(ownerProjection).isSuccess) + fetchGate.release() + outcomeGate.awaitEntered() + ownerProjection.awaitEntered() + ownerProjection.release() + ownerProjection.awaitExited() + + overlay.transform = { mustNotLeak } + overlay.clearCalls() + check(projectionAfterGates.trySend(targetProjection).isSuccess) + overlay.signals.emit(key) + assertSame(newer, overlay.awaitCall()) + targetProjection.awaitEntered() + targetProjection.release() + targetProjection.awaitExited() + + if (ordering == ForeignOwnerOrdering.READER_FIRST) { + val projected = awaitRefData() + assertSame(mustNotLeak, projected.value) + assertEquals(Origin.OVERLAY, projected.origin) + assertEquals(Origin.OVERLAY, telemetry.awaitServe(Origin.OVERLAY)) + outcomeGate.release() + assertSame(newer, awaitFromDefault { foreignOwner.await() }) + cancelAndIgnoreRemainingEvents() + } else { + expectNoEvents() + assertFalse(telemetry.serves.contains(Origin.OVERLAY)) + outcomeGate.release() + assertSame(newer, awaitFromDefault { foreignOwner.await() }) + cancelAndIgnoreRemainingEvents() + } + } + } finally { + fetchGate.release() + outcomeGate.release() + preWriteMapping.release() + orderingMapping.release() + trailingMapping.release() + initialObserverProjection.release() + writerProjection.release() + ownerProjection.release() + targetProjection.release() + harness.close() + } + } + + @Test + fun policyWithheldNonNullBase_doesNotAuthorizeProjection() = runTest { + var calls = 0 + val refresh = SuspendGate() + val overlay = CountingOverlay({ base -> base?.plus("+optimistic") }) + val store = store { + fetcher { + when (++calls) { + 1 -> "v1" + 2 -> { + refresh.pause() + "v2" + } + else -> error("unexpected fetch $calls") + } + } + overlay(overlay) + } + + try { + store.stream(key).test { + awaitDataValue("v1+optimistic") + cancelAndIgnoreRemainingEvents() + } + store.invalidate(key) + store.stream(key, Freshness.MustBeFresh).test { + assertIs(awaitItem()) + refresh.awaitEntered() + expectNoEvents() + refresh.release() + awaitDataValue("v2+optimistic") + cancelAndIgnoreRemainingEvents() + } + } finally { + refresh.release() + store.close() + } + } + + @Test + fun nullOverlay_preservesEqualButDistinctForeignBaselineReuse() = runTest { + val visible = RefValue("visible", "visible") + val mapped = RefValue("equal", "value") + val ownerValue = RefValue("equal", "value") + assertEquals(mapped, ownerValue) + assertTrue(mapped !== ownerValue) + val source = ContractSourceOfTruth() + val mappedDelivery = SuspendGate() + var blockMapped = false + val harness = + refEngine( + overlay = null, + sot = source, + fetcher = { FetcherResult.NotModified("e1") }, + beforeReaderDeliveryLockTestGate = { record -> + if ( + blockMapped && + record is ReaderRecord.Row && + record.envelope.value === mapped + ) { + blockMapped = false + mappedDelivery.pause() + } + }, + ) + + try { + harness.engine.applyWrite(visible) + harness.engine.stream(Freshness.LocalOnly).test { + assertSame(visible, awaitRefData().value) + source.readerStarted.await() + + blockMapped = true + source.write(key, mapped) + mappedDelivery.awaitEntered() + harness.engine.applyWrite(ownerValue) + assertSame(ownerValue, harness.engine.get(Freshness.MustBeFresh)) + + mappedDelivery.release() + assertSame(mapped, awaitRefData().value) + cancelAndIgnoreRemainingEvents() + } + } finally { + mappedDelivery.release() + harness.close() + } + } + + @Test + fun confirmedAbsenceAuthorizesCreate_readerFailureDoesNot() = runTest { + val create = CountingOverlay({ it ?: "optimistic-create" }) + val absentStore = store { + fetcher { awaitCancellation() } + overlay(create) + } + val readerBoom = IllegalStateException("reader failed") + val liveReaderFailure = SuspendGate() + val projectionDelivery = SuspendGate() + val failingHarness = + stringEngine( + overlay = CountingOverlay({ it ?: "must-not-appear" }), + fetcher = { awaitCancellation() }, + sot = FailingReaderSourceOfTruth(readerBoom, liveReaderFailure), + afterProjectionDeliveryTestGate = { projectionDelivery.pause() }, + ) + + try { + absentStore.stream(key).test { + val created = awaitDataValue("optimistic-create") + assertEquals(Origin.OVERLAY, created.origin) + cancelAndIgnoreRemainingEvents() + } + failingHarness.engine.stream(Freshness.CachedOrFetch).test { + assertIs(awaitItem()) + val failure = assertIs(awaitItem()) + assertIs(failure.error) + + liveReaderFailure.awaitEntered() + projectionDelivery.awaitEntered() + expectNoEvents() // the ready optimistic-create snapshot was observed but rejected + + projectionDelivery.release() + liveReaderFailure.release() + val liveFailure = assertIs(awaitItem()) + assertIs(liveFailure.error) + cancelAndIgnoreRemainingEvents() + } + } finally { + liveReaderFailure.release() + projectionDelivery.release() + absentStore.close() + failingHarness.close() + } + } + + @Test + fun identityProjection_preservesLateCollectorMemoryOrigin() = runTest { + val store = store { + fetcher { "v" } + overlay(CountingOverlay({ it })) + } + + try { + assertEquals("v", store.get(key)) + store.stream(key, Freshness.LocalOnly).test { + val data = awaitDataValue("v") + assertEquals(Origin.MEMORY, data.origin) + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun revalidated_identityHasNoExtraData_andTelemetryUsesEffectiveOrigin() = runTest { + val telemetry = RecordingTelemetry() + revalidationScenario( + overlay = CountingOverlay({ it }), + telemetry = telemetry, + ) { turbine -> + val revalidated = turbine.awaitNonDataTerminal() + assertIs(revalidated) + turbine.expectNoEvents() + assertEquals(listOf(Origin.FETCHER), telemetry.serves) + } + } + + @Test + fun revalidated_overlaidOrdersProjectionThenLifecycle_andTelemetryUsesOverlay() = runTest { + val telemetry = RecordingTelemetry() + var suffix = "one" + val overlay = CountingOverlay({ base -> base?.plus("+$suffix") }) + revalidationScenario( + overlay = overlay, + telemetry = telemetry, + beforeRelease = { + suffix = "two" + overlay.signals.emit(key) + }, + ) { turbine -> + val data = assertIs>(turbine.awaitItem()) + assertEquals("v+two", data.value) + assertEquals(Origin.OVERLAY, data.origin) + assertIs(turbine.awaitItem()) + assertEquals(listOf(Origin.OVERLAY, Origin.OVERLAY), telemetry.serves) + } + } + + @Test + fun revalidated_unchangedOverlaidHasNoExtraData_andSingleTelemetryHook() = runTest { + val telemetry = RecordingTelemetry() + revalidationScenario( + overlay = CountingOverlay({ base -> base?.let { "stable-overlay" } }), + telemetry = telemetry, + ) { turbine -> + val terminal = turbine.awaitNonDataTerminal() + val fetchFailure = + (terminal as? StoreResult.Error)?.error as? StoreError.Fetch + assertIs(terminal, fetchFailure?.cause?.message) + turbine.expectNoEvents() + assertEquals(listOf(Origin.OVERLAY), telemetry.serves) + } + } + + @Test + fun revalidated_absentOrdersLoadingThenLifecycle_andSkipsTelemetry() = runTest { + val telemetry = RecordingTelemetry() + var absent = false + val overlay = CountingOverlay({ base -> if (absent) null else base }) + revalidationScenario( + overlay = overlay, + telemetry = telemetry, + beforeRelease = { + absent = true + overlay.signals.emit(key) + }, + ) { turbine -> + assertIs(turbine.awaitItem()) + assertIs(turbine.awaitItem()) + assertTrue(telemetry.serves.isEmpty()) + } + } + + @Test + fun failedFetch_flushesOverlaidProjectionBeforeError_andReportsServedStale() = runTest { + failedProjectionScenario(projectAbsent = false) { events -> + assertEquals(listOf("data:v+latest", "error:true"), events) + } + } + + @Test + fun failedFetch_flushesAbsentProjectionBeforeError_andClearsServedStale() = runTest { + failedProjectionScenario(projectAbsent = true) { events -> + assertEquals(listOf("loading", "error:false"), events) + } + } + + @Test + fun serverDelete_projectsExactAbsenceBeforeTerminalError() = runTest { + var calls = 0 + val deletion = SuspendGate() + val overlay = CountingOverlay({ base -> base ?: "optimistic-after-delete" }) + val store = store { + fetcherOfResult { + when (++calls) { + 1 -> FetcherResult.Success("v") + 2 -> { + deletion.pause() + FetcherResult.Deleted + } + else -> error("unexpected fetch $calls") + } + } + overlay(overlay) + } + + try { + store.stream(key).test { + awaitDataValue("v") + store.invalidate(key) + deletion.awaitEntered() + deletion.release() + + val optimistic = awaitDataValue("optimistic-after-delete") + assertEquals(Origin.OVERLAY, optimistic.origin) + val failure = assertIs(awaitItem()) + assertIs(failure.error) + cancelAndIgnoreRemainingEvents() + } + } finally { + deletion.release() + store.close() + } + } + + @Test + fun mustBeFreshInitialDelete_projectsExactAbsenceBeforeTerminalError() = runTest { + val deletion = SuspendGate() + val overlay = CountingOverlay({ base -> base ?: "optimistic-after-delete" }) + val store = store { + fetcherOfResult { + deletion.pause() + FetcherResult.Deleted + } + overlay(overlay) + } + + try { + store.runtime()!!.writeHandle.apply(key, "confirmed") + store.stream(key, Freshness.MustBeFresh).test { + assertIs(awaitItem()) + deletion.awaitEntered() + deletion.release() + assertEquals("optimistic-after-delete", awaitDataValue().value) + assertIs(assertIs(awaitItem()).error) + cancelAndIgnoreRemainingEvents() + } + } finally { + deletion.release() + store.close() + } + } + + @Test + fun mustBeFreshInitialFailure_flushesLatestProjectionBeforeError() = runTest { + val fetch = SuspendGate() + val boom = IllegalStateException("offline") + var projected = "one" + val overlay = CountingOverlay({ base -> base ?: projected }) + val store = store { + fetcherOfResult { + fetch.pause() + FetcherResult.Error(boom) + } + overlay(overlay) + } + + try { + store.stream(key, Freshness.MustBeFresh).test { + awaitDataValue("one") + fetch.awaitEntered() + overlay.clearCalls() + projected = "two" + overlay.signals.emit(key) + assertEquals(null, overlay.awaitCall()) + fetch.release() + + assertEquals("two", awaitDataValue().value) + assertIs(assertIs(awaitItem()).error) + cancelAndIgnoreRemainingEvents() + } + } finally { + fetch.release() + store.close() + } + } + + @Test + fun mustBeFreshWaitingFetch_observesOverlayTerminalForCurrentAndFutureStreams() = runTest { + val boom = IllegalStateException("projection failed while fetch waits") + val failChanges = CompletableDeferred() + val overlay = + CountingOverlay( + changes = flow { + failChanges.await() + throw boom + }, + transform = { base -> base }, + ) + val store = store { + fetcher { awaitCancellation() } + overlay(overlay) + } + + try { + store.runtime()!!.writeHandle.apply(key, "resident") + store.stream(key, Freshness.LocalOnly).test { + awaitDataValue("resident") + cancelAndIgnoreRemainingEvents() + } + + store.stream(key, Freshness.MustBeFresh).test { + assertIs(awaitItem()) + failChanges.complete(Unit) + + val terminal = assertIs(awaitError()) + assertEquals(boom::class, terminal.cause!!::class) + assertEquals(boom.message, terminal.cause?.message) + } + + val futureFailure = + runCatching { + store.stream(key, Freshness.MustBeFresh).collect { } + }.exceptionOrNull() + val futureTerminal = assertIs(futureFailure) + assertEquals(boom::class, futureTerminal.cause!!::class) + assertEquals(boom.message, futureTerminal.cause?.message) + } finally { + failChanges.complete(Unit) + store.close() + } + } + + @Test + fun applyFailureWithLiveChangesCollector_retainsOriginalCauseForCurrentAndFutureStreams() = + runTest { + val boom = IllegalStateException("apply failed with live changes collector") + val failProjection = MutableStateFlow(false) + val signals = MutableSharedFlow(replay = 1) + val overlay = + object : Overlay { + override val changes: Flow = signals + + override fun apply( + key: TestKey, + base: String?, + ): String? { + if (failProjection.value) throw boom + return base + } + } + val store = store { + fetcher { awaitCancellation() } + overlay(overlay) + } + + try { + store.runtime()!!.writeHandle.apply(key, "resident") + store.stream(key, Freshness.LocalOnly).test { + awaitDataValue("resident") + cancelAndIgnoreRemainingEvents() + } + withContext(Dispatchers.Default) { + signals.subscriptionCount.first { count -> count > 0 } + } + + store.stream(key, Freshness.MustBeFresh).test { + assertIs(awaitItem()) + failProjection.value = true + signals.emit(key) + + val terminal = assertIs(awaitError()) + assertEquals(boom::class, terminal.cause!!::class) + assertEquals(boom.message, terminal.cause?.message) + } + + assertTerminalCause(store, boom) + } finally { + store.close() + } + } + + @Test + fun closeCancelsPendingReadiness_withoutTerminalizingCooperativeClose() = runTest { + val pending = SuspendGate() + val readiness = SuspendGate() + val harness = + stringEngine( + overlay = CountingOverlay({ it }), + fetcher = { awaitCancellation() }, + beforeProjectionApplyTestGate = { pending.pause() }, + beforeProjectionReadinessWaitTestGate = { readiness.pause() }, + ) + + try { + val collection = async(Dispatchers.Default) { + harness.engine.stream(Freshness.LocalOnly).collect { } + } + pending.awaitEntered() + readiness.awaitEntered() + harness.close() + readiness.release() + val failure = runCatching { awaitFromDefault { collection.await() } }.exceptionOrNull() + assertIs(failure) + } finally { + pending.release() + readiness.release() + harness.close() + } + } + + @Test + fun throwingApplyTerminalizesCurrentAndFutureStreams() = runTest { + val boom = IllegalStateException("apply failed") + val store = store { + fetcher { awaitCancellation() } + overlay(ThrowingOverlay(boom, emptyFlow())) + } + + try { + assertTerminalCause(store, boom) + assertTerminalCause(store, boom) + } finally { + store.close() + } + } + + @Test + fun selfOriginatedCancellationFromApplyTerminalizesWhileEngineIsActive() = runTest { + val boom = CancellationException("callback cancelled itself") + val store = store { + fetcher { awaitCancellation() } + overlay(ThrowingOverlay(boom, emptyFlow())) + } + + try { + assertTerminalCause(store, boom) + } finally { + store.close() + } + } + + @Test + fun failingChangesTerminalizesCurrentAndFutureStreams() = runTest { + val boom = IllegalStateException("changes failed") + val store = store { + fetcher { awaitCancellation() } + overlay(ThrowingOverlay(null, flow { throw boom })) + } + + try { + assertTerminalCause(store, boom) + assertTerminalCause(store, boom) + } finally { + store.close() + } + } + + @Test + fun selfOriginatedCancellationFromChangesTerminalizesWhileEngineIsActive() = runTest { + val boom = CancellationException("changes cancelled itself") + val store = store { + fetcher { awaitCancellation() } + overlay(ThrowingOverlay(null, flow { throw boom })) + } + + try { + assertTerminalCause(store, boom) + } finally { + store.close() + } + } + + @Test + fun normalChangesCompletion_keepsResidenceProjectionActive() = runTest { + val overlay = CountingOverlay(changes = emptyFlow()) { base -> base?.plus("+op") } + val store = store { + fetcher { awaitCancellation() } + overlay(overlay) + } + + try { + store.stream(key).test { + assertIs(awaitItem()) + store.runtime()!!.writeHandle.apply(key, "confirmed") + val data = awaitDataValue("confirmed+op") + assertEquals(Origin.OVERLAY, data.origin) + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun rapidSignals_haveExactAcceptedCountsIncludingEqualOutputAndMismatch() = runTest { + val overlay = CountingOverlay({ "stable" }) + val source = ContractSourceOfTruth() + val harness = + stringEngine( + overlay = overlay, + sot = source, + fetcher = { awaitCancellation() }, + ) + + try { + harness.engine.applyWrite("base") + overlay.awaitCallValue("base") + overlay.clearCalls() + harness.engine.stream(Freshness.LocalOnly).test { + awaitDataValue("stable") + source.readerStarted.await() + overlay.awaitCallValue("base") + overlay.clearCalls() + val callsBeforeSignals = overlay.callCount + + overlay.signals.emit(TestKey("different")) + overlay.signals.emit(key) + assertEquals("base", overlay.awaitCall()) + expectNoEvents() // equal projection still invokes apply but emits no duplicate Data + + overlay.signals.emit(key) + assertEquals("base", overlay.awaitCall()) + expectNoEvents() + assertEquals(callsBeforeSignals + 2, overlay.callCount) + cancelAndIgnoreRemainingEvents() + } + } finally { + harness.close() + } + } + + @Test + fun twoCollectors_doNotIncreaseGlobalApplyCount() = runTest { + val overlay = CountingOverlay({ "one-global-projection" }) + val source = ContractSourceOfTruth() + val harness = + stringEngine( + overlay = overlay, + sot = source, + fetcher = { awaitCancellation() }, + ) + + try { + harness.engine.applyWrite("base") + overlay.awaitCallValue("base") + overlay.clearCalls() + turbineScope { + val first = harness.engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("one-global-projection", first.awaitDataValue().value) + source.readerStarted.await() + overlay.awaitCallValue("base") + overlay.clearCalls() + overlay.signals.emit(key) + assertEquals("base", overlay.awaitCall()) + val callsBeforeSecondCollector = overlay.callCount + + val second = harness.engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("one-global-projection", second.awaitDataValue().value) + overlay.clearCalls() + overlay.signals.emit(key) + assertEquals("base", overlay.awaitCall()) + assertEquals(callsBeforeSecondCollector + 1, overlay.callCount) + first.cancelAndIgnoreRemainingEvents() + second.cancelAndIgnoreRemainingEvents() + } + } finally { + harness.close() + } + } + + @Test + fun nullOverlay_preservesLandedBehaviorWithoutProjectionCallbacks() = runTest { + val neverInstalled = CountingOverlay({ error("must never run") }) + val plain = store { fetcher { "v" } } + + try { + plain.stream(key).test { + assertIs(awaitItem()) + val data = assertIs>(awaitItem()) + assertEquals("v", data.value) + assertEquals(Origin.FETCHER, data.origin) + expectNoEvents() + assertEquals(0, neverInstalled.callCount) + cancelAndIgnoreRemainingEvents() + } + } finally { + plain.close() + } + } + + private suspend fun TestScope.revalidationScenario( + overlay: CountingOverlay, + telemetry: RecordingTelemetry, + beforeRelease: suspend () -> Unit = {}, + verify: suspend (ReceiveTurbine>) -> Unit, + ) { + var calls = 0 + val gate = SuspendGate() + val store = store { + fetcherOfResult { + when (++calls) { + 1 -> FetcherResult.Success("v", etag = "e1") + 2 -> { + gate.pause() + FetcherResult.NotModified("e2") + } + else -> error("unexpected fetch $calls") + } + } + overlay(overlay) + telemetry(telemetry) + } + + try { + store.stream(key).test { + awaitDataValue() + telemetry.awaitServeCausally() + telemetry.clear() + store.invalidate(key) + gate.awaitEnteredCausally() + beforeRelease() + gate.release() + verify(this) + cancelAndIgnoreRemainingEvents() + } + } finally { + gate.release() + store.close() + } + } + + private suspend fun TestScope.failedProjectionScenario( + projectAbsent: Boolean, + verify: (List) -> Unit, + ) { + var calls = 0 + var latest = false + val fetchGate = SuspendGate() + val projectionGate = SuspendGate() + var blockLatest = false + val boom = IllegalStateException("fetch failed") + val overlay = CountingOverlay({ base -> + when { + !latest -> base?.plus("+initial") + projectAbsent -> null + else -> base?.plus("+latest") + } + }) + val harness = + stringEngine( + overlay = overlay, + fetcher = { + when (++calls) { + 1 -> FetcherResult.Success("v") + 2 -> { + fetchGate.pause() + FetcherResult.Error(boom) + } + else -> error("unexpected fetch $calls") + } + }, + afterProjectionApplyTestGate = { base -> + if (base == "v" && blockLatest) { + blockLatest = false + projectionGate.pause() + } + }, + ) + + try { + harness.engine.stream(Freshness.StaleIfError).test { + awaitDataValue("v+initial") + harness.engine.invalidate() + fetchGate.awaitEntered() + latest = true + blockLatest = true + overlay.signals.emit(key) + projectionGate.awaitEntered() + fetchGate.release() + projectionGate.release() + + val events = mutableListOf() + while (events.none { it.startsWith("error:") }) { + when (val item = awaitItem()) { + is StoreResult.Data -> events += "data:${item.value}" + is StoreResult.Loading -> events += "loading" + is StoreResult.Error -> events += "error:${item.servedStale}" + is StoreResult.Revalidated -> events += "revalidated" + } + } + verify(events) + cancelAndIgnoreRemainingEvents() + } + } finally { + fetchGate.release() + projectionGate.release() + harness.close() + } + } + + private fun TestScope.refEngine( + overlay: Overlay?, + fetcher: suspend (TestKey) -> FetcherResult, + sot: SourceOfTruth = SharedFlowSourceOfTruth(), + beforeReaderRecordMappingTestGate: suspend () -> Unit = {}, + beforeReaderDeliveryLockTestGate: suspend (ReaderRecord) -> Unit = {}, + beforeProjectionApplyTestGate: suspend (RefValue?) -> Unit = {}, + afterProjectionApplyTestGate: suspend (RefValue?) -> Unit = {}, + beforeReaderDeliveryTestGate: suspend () -> Unit = {}, + beforeTicketOutcomeDeliveryTestGate: suspend () -> Unit = {}, + beforeProjectionDeliveryLockTestGate: suspend () -> Unit = {}, + beforeProjectionDeliveryTestGate: suspend () -> Unit = {}, + afterProjectionDeliveryTestGate: suspend () -> Unit = {}, + beforeProjectionReadinessWaitTestGate: suspend () -> Unit = {}, + telemetry: StoreTelemetry? = null, + ): EngineHarness = + engineTyped( + overlay, + fetcher, + sot, + beforeReaderRecordMappingTestGate, + beforeReaderDeliveryLockTestGate, + beforeProjectionApplyTestGate, + afterProjectionApplyTestGate, + beforeReaderDeliveryTestGate, + beforeTicketOutcomeDeliveryTestGate, + beforeProjectionDeliveryLockTestGate, + beforeProjectionDeliveryTestGate, + afterProjectionDeliveryTestGate, + beforeProjectionReadinessWaitTestGate, + telemetry, + ) + + private fun TestScope.stringEngine( + overlay: Overlay?, + fetcher: suspend (TestKey) -> FetcherResult, + sot: SourceOfTruth = SharedFlowSourceOfTruth(), + beforeReaderRecordMappingTestGate: suspend () -> Unit = {}, + beforeReaderDeliveryLockTestGate: suspend (ReaderRecord) -> Unit = {}, + beforeProjectionApplyTestGate: suspend (String?) -> Unit = {}, + afterProjectionApplyTestGate: suspend (String?) -> Unit = {}, + beforeReaderDeliveryTestGate: suspend () -> Unit = {}, + beforeTicketOutcomeDeliveryTestGate: suspend () -> Unit = {}, + beforeProjectionDeliveryLockTestGate: suspend () -> Unit = {}, + beforeProjectionDeliveryTestGate: suspend () -> Unit = {}, + afterProjectionDeliveryTestGate: suspend () -> Unit = {}, + beforeProjectionReadinessWaitTestGate: suspend () -> Unit = {}, + telemetry: StoreTelemetry? = null, + ): EngineHarness = + engineTyped( + overlay, + fetcher, + sot, + beforeReaderRecordMappingTestGate, + beforeReaderDeliveryLockTestGate, + beforeProjectionApplyTestGate, + afterProjectionApplyTestGate, + beforeReaderDeliveryTestGate, + beforeTicketOutcomeDeliveryTestGate, + beforeProjectionDeliveryLockTestGate, + beforeProjectionDeliveryTestGate, + afterProjectionDeliveryTestGate, + beforeProjectionReadinessWaitTestGate, + telemetry, + ) + + private fun TestScope.engineTyped( + overlay: Overlay?, + fetcher: suspend (TestKey) -> FetcherResult, + sot: SourceOfTruth, + beforeReaderRecordMappingTestGate: suspend () -> Unit, + beforeReaderDeliveryLockTestGate: suspend (ReaderRecord) -> Unit, + beforeProjectionApplyTestGate: suspend (V?) -> Unit, + afterProjectionApplyTestGate: suspend (V?) -> Unit, + beforeReaderDeliveryTestGate: suspend () -> Unit, + beforeTicketOutcomeDeliveryTestGate: suspend () -> Unit, + beforeProjectionDeliveryLockTestGate: suspend () -> Unit, + beforeProjectionDeliveryTestGate: suspend () -> Unit, + afterProjectionDeliveryTestGate: suspend () -> Unit, + beforeProjectionReadinessWaitTestGate: suspend () -> Unit, + telemetry: StoreTelemetry?, + ): EngineHarness { + val job = SupervisorJob(backgroundScope.coroutineContext[Job]) + val scope = CoroutineScope(Dispatchers.Default + job) + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher(fetcher), + sot = sot, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = org.mobilenativefoundation.store6.core.FakeWallClock(0L), + engineScope = scope, + telemetry = telemetry, + beforeReaderRecordMappingTestGate = beforeReaderRecordMappingTestGate, + beforeReaderDeliveryLockTestGate = beforeReaderDeliveryLockTestGate, + beforeReaderDeliveryTestGate = beforeReaderDeliveryTestGate, + beforeTicketOutcomeDeliveryTestGate = beforeTicketOutcomeDeliveryTestGate, + overlay = overlay, + beforeProjectionApplyTestGate = beforeProjectionApplyTestGate, + afterProjectionApplyTestGate = afterProjectionApplyTestGate, + beforeProjectionDeliveryLockTestGate = beforeProjectionDeliveryLockTestGate, + beforeProjectionDeliveryTestGate = beforeProjectionDeliveryTestGate, + afterProjectionDeliveryTestGate = afterProjectionDeliveryTestGate, + beforeProjectionReadinessWaitTestGate = beforeProjectionReadinessWaitTestGate, + ) + return EngineHarness(engine, job) + } + + private suspend fun assertTerminalCause( + store: org.mobilenativefoundation.store6.core.Store, + expected: Throwable, + ) { + val failure = runCatching { store.stream(key, Freshness.LocalOnly).collect { } }.exceptionOrNull() + val terminal = assertIs(failure) + assertEquals(expected::class, terminal.cause!!::class) + assertEquals(expected.message, terminal.cause?.message) + } + + private suspend fun ReceiveTurbine>.awaitDataValue( + expected: String? = null, + ): StoreResult.Data { + while (true) { + val item = awaitItem() + if (item is StoreResult.Data) { + seenDataValues += item.value + if (expected == null || item.value == expected) return item + } + } + } + + private val seenDataValues = mutableListOf() + + private val seenDataTags = mutableListOf() + + private suspend fun ReceiveTurbine>.awaitDataTag( + expected: String, + ): StoreResult.Data { + while (true) { + val item = awaitItem() + if (item is StoreResult.Data) { + seenDataTags += item.value.tag + if (item.value.tag == expected) return item + } + } + } + + private suspend fun ReceiveTurbine>.awaitRefData(): StoreResult.Data { + while (true) { + val item = awaitItem() + if (item is StoreResult.Data) { + seenDataTags += item.value.tag + return item + } + } + } + + private suspend fun ReceiveTurbine>.awaitNonDataTerminal(): StoreResult { + while (true) { + when (val item = awaitItem()) { + is StoreResult.Data -> + error( + "unexpected extra Data(" + + "value=${item.value}, origin=${item.origin}, age=${item.age}, " + + "isStale=${item.isStale}, refreshing=${item.refreshing})", + ) + is StoreResult.Revalidated, + is StoreResult.Error, + -> return item + is StoreResult.Loading -> Unit + } + } + } + + // Turbine's 3s default would nest inside the 25s shadow. Raising the Turbine deadline above + // the shadow makes runTest the only effective timeout. + private val TEST_TIMEOUT = 25.seconds + private val TURBINE_DEADLINE = 30.seconds // strictly > TEST_TIMEOUT: the shadow must fire first + + private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } + + // Keep Default-dispatch ordering, but let runTest provide the only timeout. Short nested + // wall-clock deadlines are scheduler-sensitive under the broad root build graph. + private suspend fun awaitFromDefault(block: suspend () -> T): T = + withContext(Dispatchers.Default) { + block() + } + + private class SuspendGate { + private val entered = CompletableDeferred() + private val released = CompletableDeferred() + private val exited = CompletableDeferred() + + suspend fun pause() { + entered.complete(Unit) + try { + released.await() + } finally { + exited.complete(Unit) + } + } + + suspend fun awaitEntered() { + withContext(Dispatchers.Default) { + entered.await() + } + } + + suspend fun awaitEnteredCausally() { + entered.await() + } + + suspend fun awaitExited() { + withContext(Dispatchers.Default) { + exited.await() + } + } + + fun release() { + released.complete(Unit) + } + } + + private class CountingOverlay( + var transform: (V?) -> V?, + private val suppliedChanges: Flow? = null, + ) : Overlay { + constructor( + changes: Flow, + transform: (V?) -> V?, + ) : this(transform, changes) + + val signals = MutableSharedFlow(replay = 1) + private val calls = Channel(Channel.UNLIMITED) + var callCount: Int = 0 + private set + + override fun apply( + key: TestKey, + base: V?, + ): V? { + callCount += 1 + check(calls.trySend(base).isSuccess) + return transform(base) + } + + override val changes: Flow + get() = suppliedChanges ?: signals + + suspend fun awaitCall(): V? = + withContext(Dispatchers.Default) { + calls.receive() + } + + suspend fun awaitCallValue(expected: V) { + while (awaitCall() != expected) Unit + } + + fun clearCalls() { + while (calls.tryReceive().isSuccess) Unit + } + } + + private class ThrowingOverlay( + private val applyFailure: Throwable?, + override val changes: Flow, + ) : Overlay { + override fun apply( + key: TestKey, + base: String?, + ): String? { + applyFailure?.let { throw it } + return base + } + } + + private class FailingReaderSourceOfTruth( + private val failure: Throwable, + private val liveReaderFailure: SuspendGate? = null, + ) : SourceOfTruth { + private val startupCollection = CompletableDeferred() + + override fun reader(key: TestKey): Flow = flow { + if (!startupCollection.complete(Unit)) liveReaderFailure?.pause() + throw failure + } + + override suspend fun write( + key: TestKey, + value: String, + ) = Unit + + override suspend fun delete(key: TestKey) = Unit + + override suspend fun deleteNamespace(namespace: org.mobilenativefoundation.store6.core.StoreNamespace) = Unit + + override suspend fun deleteAll() = Unit + } + + /** Contract-honoring replay source with deterministic reader enrollment. */ + private class ContractSourceOfTruth : SourceOfTruth { + private val rows = MutableSharedFlow(replay = 1).also { check(it.tryEmit(null)) } + val readerStarted = CompletableDeferred() + + override fun reader(key: TestKey): Flow = flow { + readerStarted.complete(Unit) + emitAll(rows) + } + + override suspend fun write( + key: TestKey, + value: V, + ) { + rows.emit(value) + } + + override suspend fun delete(key: TestKey) { + rows.emit(null) + } + + override suspend fun deleteNamespace(namespace: org.mobilenativefoundation.store6.core.StoreNamespace) { + rows.emit(null) + } + + override suspend fun deleteAll() { + rows.emit(null) + } + } + + private class RecordingTelemetry : StoreTelemetry { + val serves = mutableListOf() + private val serveEvents = Channel(Channel.UNLIMITED) + + override fun onServe( + key: StoreKey, + origin: Origin, + ) { + serves += origin + check(serveEvents.trySend(origin).isSuccess) + } + + suspend fun awaitServe(expected: Origin? = null): Origin { + while (true) { + val observed = + withContext(Dispatchers.Default) { + serveEvents.receive() + } + if (expected == null || observed == expected) return observed + } + } + + suspend fun awaitServeCausally(expected: Origin? = null): Origin { + while (true) { + val observed = serveEvents.receive() + if (expected == null || observed == expected) return observed + } + } + + fun clear() { + serves.clear() + while (serveEvents.tryReceive().isSuccess) Unit + } + } + + private data class RefValue( + val id: String, + val tag: String, + ) + + private enum class ForeignOwnerOrdering { + READER_FIRST, + OWNER_FIRST, + } + + private class EngineHarness( + val engine: KeyEngine, + private val job: Job, + ) { + fun close() { + job.cancel() + } + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/ProjectionAuthorizationHandoffTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/ProjectionAuthorizationHandoffTest.kt new file mode 100644 index 000000000..b5cb136a1 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/ProjectionAuthorizationHandoffTest.kt @@ -0,0 +1,1029 @@ +package org.mobilenativefoundation.store6.core.internal + +import app.cash.turbine.ReceiveTurbine +import app.cash.turbine.testIn +import app.cash.turbine.turbineScope +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.FakeWallClock +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.SingleRowTestSourceOfTruth +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.TestKey +import org.mobilenativefoundation.store6.core.seam.Overlay +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class, ExperimentalCoroutinesApi::class) +class ProjectionAuthorizationHandoffTest { + @Test + fun mappingBeforeConfirmFresh_deliveryBeforeConfirmFresh_retirementBeforeConfirmFresh_successorAbsent_invalidationAbsent() = + runMatrixCell( + mappingBeforeConfirmFresh = true, + deliveryBeforeConfirmFresh = true, + retirementBeforeConfirmFresh = true, + successorPresent = false, + invalidationInterleaved = false, + ) + + @Test + fun mappingBeforeConfirmFresh_deliveryBeforeConfirmFresh_retirementBeforeConfirmFresh_successorAbsent_invalidationPresent() = + runMatrixCell( + mappingBeforeConfirmFresh = true, + deliveryBeforeConfirmFresh = true, + retirementBeforeConfirmFresh = true, + successorPresent = false, + invalidationInterleaved = true, + ) + + @Test + fun mappingBeforeConfirmFresh_deliveryBeforeConfirmFresh_retirementBeforeConfirmFresh_successorPresent_invalidationAbsent() = + runMatrixCell( + mappingBeforeConfirmFresh = true, + deliveryBeforeConfirmFresh = true, + retirementBeforeConfirmFresh = true, + successorPresent = true, + invalidationInterleaved = false, + ) + + @Test + fun mappingBeforeConfirmFresh_deliveryBeforeConfirmFresh_retirementBeforeConfirmFresh_successorPresent_invalidationPresent() = + runMatrixCell( + mappingBeforeConfirmFresh = true, + deliveryBeforeConfirmFresh = true, + retirementBeforeConfirmFresh = true, + successorPresent = true, + invalidationInterleaved = true, + ) + + @Test + fun mappingBeforeConfirmFresh_deliveryBeforeConfirmFresh_retirementAfterConfirmFresh_successorAbsent_invalidationAbsent() = + runMatrixCell( + mappingBeforeConfirmFresh = true, + deliveryBeforeConfirmFresh = true, + retirementBeforeConfirmFresh = false, + successorPresent = false, + invalidationInterleaved = false, + ) + + @Test + fun mappingBeforeConfirmFresh_deliveryBeforeConfirmFresh_retirementAfterConfirmFresh_successorAbsent_invalidationPresent() = + runMatrixCell( + mappingBeforeConfirmFresh = true, + deliveryBeforeConfirmFresh = true, + retirementBeforeConfirmFresh = false, + successorPresent = false, + invalidationInterleaved = true, + ) + + @Test + fun mappingBeforeConfirmFresh_deliveryBeforeConfirmFresh_retirementAfterConfirmFresh_successorPresent_invalidationAbsent() = + runMatrixCell( + mappingBeforeConfirmFresh = true, + deliveryBeforeConfirmFresh = true, + retirementBeforeConfirmFresh = false, + successorPresent = true, + invalidationInterleaved = false, + ) + + @Test + fun mappingBeforeConfirmFresh_deliveryBeforeConfirmFresh_retirementAfterConfirmFresh_successorPresent_invalidationPresent() = + runMatrixCell( + mappingBeforeConfirmFresh = true, + deliveryBeforeConfirmFresh = true, + retirementBeforeConfirmFresh = false, + successorPresent = true, + invalidationInterleaved = true, + ) + + @Test + fun mappingBeforeConfirmFresh_deliveryAfterConfirmFresh_retirementBeforeConfirmFresh_successorAbsent_invalidationAbsent() = + runMatrixCell( + mappingBeforeConfirmFresh = true, + deliveryBeforeConfirmFresh = false, + retirementBeforeConfirmFresh = true, + successorPresent = false, + invalidationInterleaved = false, + ) + + @Test + fun mappingBeforeConfirmFresh_deliveryAfterConfirmFresh_retirementBeforeConfirmFresh_successorAbsent_invalidationPresent() = + runMatrixCell( + mappingBeforeConfirmFresh = true, + deliveryBeforeConfirmFresh = false, + retirementBeforeConfirmFresh = true, + successorPresent = false, + invalidationInterleaved = true, + ) + + @Test + fun mappingBeforeConfirmFresh_deliveryAfterConfirmFresh_retirementBeforeConfirmFresh_successorPresent_invalidationAbsent() = + runMatrixCell( + mappingBeforeConfirmFresh = true, + deliveryBeforeConfirmFresh = false, + retirementBeforeConfirmFresh = true, + successorPresent = true, + invalidationInterleaved = false, + ) + + @Test + fun mappingBeforeConfirmFresh_deliveryAfterConfirmFresh_retirementBeforeConfirmFresh_successorPresent_invalidationPresent() = + runMatrixCell( + mappingBeforeConfirmFresh = true, + deliveryBeforeConfirmFresh = false, + retirementBeforeConfirmFresh = true, + successorPresent = true, + invalidationInterleaved = true, + ) + + @Test + fun mappingBeforeConfirmFresh_deliveryAfterConfirmFresh_retirementAfterConfirmFresh_successorAbsent_invalidationAbsent() = + runMatrixCell( + mappingBeforeConfirmFresh = true, + deliveryBeforeConfirmFresh = false, + retirementBeforeConfirmFresh = false, + successorPresent = false, + invalidationInterleaved = false, + ) + + @Test + fun mappingBeforeConfirmFresh_deliveryAfterConfirmFresh_retirementAfterConfirmFresh_successorAbsent_invalidationPresent() = + runMatrixCell( + mappingBeforeConfirmFresh = true, + deliveryBeforeConfirmFresh = false, + retirementBeforeConfirmFresh = false, + successorPresent = false, + invalidationInterleaved = true, + ) + + @Test + fun mappingBeforeConfirmFresh_deliveryAfterConfirmFresh_retirementAfterConfirmFresh_successorPresent_invalidationAbsent() = + runMatrixCell( + mappingBeforeConfirmFresh = true, + deliveryBeforeConfirmFresh = false, + retirementBeforeConfirmFresh = false, + successorPresent = true, + invalidationInterleaved = false, + ) + + @Test + fun mappingBeforeConfirmFresh_deliveryAfterConfirmFresh_retirementAfterConfirmFresh_successorPresent_invalidationPresent() = + runMatrixCell( + mappingBeforeConfirmFresh = true, + deliveryBeforeConfirmFresh = false, + retirementBeforeConfirmFresh = false, + successorPresent = true, + invalidationInterleaved = true, + ) + + @Test + fun mappingAfterConfirmFresh_deliveryBeforeConfirmFresh_retirementBeforeConfirmFresh_successorAbsent_invalidationAbsent() = + runMatrixCell( + mappingBeforeConfirmFresh = false, + deliveryBeforeConfirmFresh = true, + retirementBeforeConfirmFresh = true, + successorPresent = false, + invalidationInterleaved = false, + ) + + @Test + fun mappingAfterConfirmFresh_deliveryBeforeConfirmFresh_retirementBeforeConfirmFresh_successorAbsent_invalidationPresent() = + runMatrixCell( + mappingBeforeConfirmFresh = false, + deliveryBeforeConfirmFresh = true, + retirementBeforeConfirmFresh = true, + successorPresent = false, + invalidationInterleaved = true, + ) + + @Test + fun mappingAfterConfirmFresh_deliveryBeforeConfirmFresh_retirementBeforeConfirmFresh_successorPresent_invalidationAbsent() = + runMatrixCell( + mappingBeforeConfirmFresh = false, + deliveryBeforeConfirmFresh = true, + retirementBeforeConfirmFresh = true, + successorPresent = true, + invalidationInterleaved = false, + ) + + @Test + fun mappingAfterConfirmFresh_deliveryBeforeConfirmFresh_retirementBeforeConfirmFresh_successorPresent_invalidationPresent() = + runMatrixCell( + mappingBeforeConfirmFresh = false, + deliveryBeforeConfirmFresh = true, + retirementBeforeConfirmFresh = true, + successorPresent = true, + invalidationInterleaved = true, + ) + + @Test + fun mappingAfterConfirmFresh_deliveryBeforeConfirmFresh_retirementAfterConfirmFresh_successorAbsent_invalidationAbsent() = + runMatrixCell( + mappingBeforeConfirmFresh = false, + deliveryBeforeConfirmFresh = true, + retirementBeforeConfirmFresh = false, + successorPresent = false, + invalidationInterleaved = false, + ) + + @Test + fun mappingAfterConfirmFresh_deliveryBeforeConfirmFresh_retirementAfterConfirmFresh_successorAbsent_invalidationPresent() = + runMatrixCell( + mappingBeforeConfirmFresh = false, + deliveryBeforeConfirmFresh = true, + retirementBeforeConfirmFresh = false, + successorPresent = false, + invalidationInterleaved = true, + ) + + @Test + fun mappingAfterConfirmFresh_deliveryBeforeConfirmFresh_retirementAfterConfirmFresh_successorPresent_invalidationAbsent() = + runMatrixCell( + mappingBeforeConfirmFresh = false, + deliveryBeforeConfirmFresh = true, + retirementBeforeConfirmFresh = false, + successorPresent = true, + invalidationInterleaved = false, + ) + + @Test + fun mappingAfterConfirmFresh_deliveryBeforeConfirmFresh_retirementAfterConfirmFresh_successorPresent_invalidationPresent() = + runMatrixCell( + mappingBeforeConfirmFresh = false, + deliveryBeforeConfirmFresh = true, + retirementBeforeConfirmFresh = false, + successorPresent = true, + invalidationInterleaved = true, + ) + + @Test + fun mappingAfterConfirmFresh_deliveryAfterConfirmFresh_retirementBeforeConfirmFresh_successorAbsent_invalidationAbsent() = + runMatrixCell( + mappingBeforeConfirmFresh = false, + deliveryBeforeConfirmFresh = false, + retirementBeforeConfirmFresh = true, + successorPresent = false, + invalidationInterleaved = false, + ) + + @Test + fun mappingAfterConfirmFresh_deliveryAfterConfirmFresh_retirementBeforeConfirmFresh_successorAbsent_invalidationPresent() = + runMatrixCell( + mappingBeforeConfirmFresh = false, + deliveryBeforeConfirmFresh = false, + retirementBeforeConfirmFresh = true, + successorPresent = false, + invalidationInterleaved = true, + ) + + @Test + fun mappingAfterConfirmFresh_deliveryAfterConfirmFresh_retirementBeforeConfirmFresh_successorPresent_invalidationAbsent() = + runMatrixCell( + mappingBeforeConfirmFresh = false, + deliveryBeforeConfirmFresh = false, + retirementBeforeConfirmFresh = true, + successorPresent = true, + invalidationInterleaved = false, + ) + + @Test + fun mappingAfterConfirmFresh_deliveryAfterConfirmFresh_retirementBeforeConfirmFresh_successorPresent_invalidationPresent() = + runMatrixCell( + mappingBeforeConfirmFresh = false, + deliveryBeforeConfirmFresh = false, + retirementBeforeConfirmFresh = true, + successorPresent = true, + invalidationInterleaved = true, + ) + + @Test + fun mappingAfterConfirmFresh_deliveryAfterConfirmFresh_retirementAfterConfirmFresh_successorAbsent_invalidationAbsent() = + runMatrixCell( + mappingBeforeConfirmFresh = false, + deliveryBeforeConfirmFresh = false, + retirementBeforeConfirmFresh = false, + successorPresent = false, + invalidationInterleaved = false, + ) + + @Test + fun mappingAfterConfirmFresh_deliveryAfterConfirmFresh_retirementAfterConfirmFresh_successorAbsent_invalidationPresent() = + runMatrixCell( + mappingBeforeConfirmFresh = false, + deliveryBeforeConfirmFresh = false, + retirementBeforeConfirmFresh = false, + successorPresent = false, + invalidationInterleaved = true, + ) + + @Test + fun mappingAfterConfirmFresh_deliveryAfterConfirmFresh_retirementAfterConfirmFresh_successorPresent_invalidationAbsent() = + runMatrixCell( + mappingBeforeConfirmFresh = false, + deliveryBeforeConfirmFresh = false, + retirementBeforeConfirmFresh = false, + successorPresent = true, + invalidationInterleaved = false, + ) + + @Test + fun mappingAfterConfirmFresh_deliveryAfterConfirmFresh_retirementAfterConfirmFresh_successorPresent_invalidationPresent() = + runMatrixCell( + mappingBeforeConfirmFresh = false, + deliveryBeforeConfirmFresh = false, + retirementBeforeConfirmFresh = false, + successorPresent = true, + invalidationInterleaved = true, + ) + + private fun runMatrixCell( + mappingBeforeConfirmFresh: Boolean, + deliveryBeforeConfirmFresh: Boolean, + retirementBeforeConfirmFresh: Boolean, + successorPresent: Boolean, + invalidationInterleaved: Boolean, + ) = runTest { + val harness = matrixHarness("projection-authorization-matrix") + assertEquals("seed", harness.engine.get(Freshness.LocalOnly)) + + turbineScope { + val observer = harness.engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + try { + settleInitialProjection(harness, observer) + + val firstMapping = harness.mappingGate.gateNext() + val firstDelivery = harness.readerDeliveryGate.gateNext() + val firstWrite = harness.sourceOfTruth.gateNextWrite() + val firstResidenceProjection = harness.afterProjectionDeliveryGate.gateNext() + harness.overlay.clearCalls() + val apply = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + harness.engine.applyWrite("echo") + } + + firstMapping.entered.await() + firstWrite.published.await() + assertFalse(firstDelivery.entered.isCompleted) + + firstWrite.releaseReturn.complete(Unit) + apply.await() + assertProjectionCall( + call = harness.overlay.awaitCall(), + retired = false, + ) + firstResidenceProjection.entered.await() + firstResidenceProjection.releaseAndAwait() + testScheduler.runCurrent() + observer.expectNoEvents() + + val invalidateBeforePredecessorDelivery = + invalidationInterleaved && + mappingBeforeConfirmFresh && + deliveryBeforeConfirmFresh + if (invalidateBeforePredecessorDelivery) { + assertTrue(firstMapping.entered.isCompleted, "raw row was captured") + assertFalse(firstDelivery.entered.isCompleted) + harness.engine.invalidate() + testScheduler.runCurrent() + observer.expectNoEvents() + } + + if (mappingBeforeConfirmFresh) { + firstMapping.releaseAndAwait() + testScheduler.runCurrent() + assertTrue( + firstDelivery.entered.isCompleted, + "mapping-before must make the first delivery gate causally reachable", + ) + } else { + assertFalse(firstMapping.exited.isCompleted) + } + + if (deliveryBeforeConfirmFresh) { + if (mappingBeforeConfirmFresh) { + firstDelivery.entered.await() + assertTrue(firstDelivery.entered.isCompleted) + firstDelivery.releaseAndAwait() + testScheduler.runCurrent() + } else { + firstDelivery.requestRelease() + testScheduler.runCurrent() + assertFalse( + firstDelivery.entered.isCompleted, + "delivery cannot enter while its upstream mapping is held", + ) + } + } else if (mappingBeforeConfirmFresh) { + firstDelivery.entered.await() + assertFalse(firstDelivery.exited.isCompleted) + } + + if (retirementBeforeConfirmFresh) { + harness.overlay.clearCalls() + harness.overlay.retire(harness.key) + assertProjectionCall( + call = harness.overlay.awaitCall(), + retired = true, + ) + testScheduler.runCurrent() + if (mappingBeforeConfirmFresh && deliveryBeforeConfirmFresh) { + assertPredecessorConfirmed(observer) + } else { + observer.expectNoEvents() + } + } else { + observer.expectNoEvents() + } + + if (invalidationInterleaved && !invalidateBeforePredecessorDelivery) { + assertTrue(firstMapping.entered.isCompleted, "raw row was captured") + assertFalse( + firstDelivery.exited.isCompleted, + "invalidation must remain between raw capture and collector delivery", + ) + // Do not advance the scheduler before confirmFresh: the stale-epoch observer is + // itself a delivery path and must resolve the post-confirmFresh residence. + harness.engine.invalidate() + } + val heldR2 = + if (successorPresent) { + harness.beforeProjectionDeliveryGate.gateNext() + } else { + null + } + harness.engine.confirmFresh(etag = "confirmed") + testScheduler.runCurrent() + + if (!mappingBeforeConfirmFresh) { + firstMapping.releaseAndAwait() + testScheduler.runCurrent() + firstDelivery.entered.await() + assertTrue( + firstDelivery.entered.isCompleted, + "mapping release after confirmFresh must make delivery reachable", + ) + } + + if (!deliveryBeforeConfirmFresh) { + firstDelivery.entered.await() + assertFalse(firstDelivery.exited.isCompleted) + firstDelivery.releaseAndAwait() + } else if (!mappingBeforeConfirmFresh) { + firstDelivery.exited.await() + } + testScheduler.runCurrent() + + if (successorPresent) { + runSuccessorCell( + harness = harness, + observer = observer, + heldR2 = checkNotNull(heldR2), + retirementBeforeConfirmFresh = retirementBeforeConfirmFresh, + predecessorDeliveredBeforeConfirm = + mappingBeforeConfirmFresh && deliveryBeforeConfirmFresh, + ) + } else { + finishWithoutSuccessor( + harness = harness, + observer = observer, + retirementBeforeConfirmFresh = retirementBeforeConfirmFresh, + ) + } + } finally { + harness.releaseAll() + observer.cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun consecutiveConfirmFresh_coalescedReadyPreservesStableAuthorizationLineage() = runTest { + val harness = matrixHarness("projection-authorization-consecutive-confirm") + assertEquals("seed", harness.engine.get(Freshness.LocalOnly)) + + turbineScope { + val observer = harness.engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + try { + settleInitialProjection(harness, observer) + authorizeR1(harness, observer) + + harness.overlay.clearCalls() + val heldR2 = harness.beforeProjectionDeliveryGate.gateNext() + harness.engine.confirmFresh(etag = "r2") + heldR2.entered.await() + assertProjectionCall(harness.overlay.awaitCall(), retired = false) + + harness.overlay.clearCalls() + harness.engine.confirmFresh(etag = "r3") + assertProjectionCall(harness.overlay.awaitCall(), retired = false) + testScheduler.runCurrent() + + val heldR3 = harness.beforeProjectionDeliveryGate.gateNext() + val staleR2Delivered = harness.afterProjectionDeliveryGate.gateNext() + heldR2.releaseAndAwait() + staleR2Delivered.entered.await() + observer.expectNoEvents() + staleR2Delivered.releaseAndAwait() + + heldR3.entered.await() + val currentR3Delivered = harness.afterProjectionDeliveryGate.gateNext() + heldR3.releaseAndAwait() + currentR3Delivered.entered.await() + observer.expectNoEvents() + currentR3Delivered.releaseAndAwait() + + val retirementDelivered = harness.afterProjectionDeliveryGate.gateNext() + harness.overlay.clearCalls() + harness.overlay.retire(harness.key) + assertProjectionCall(harness.overlay.awaitCall(), retired = true) + retirementDelivered.entered.await() + assertLatestConfirmed(observer) + retirementDelivered.releaseAndAwait() + } finally { + harness.releaseAll() + observer.cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun readerAwaitingObsoleteR1Readiness_handsAuthorizationToConfirmFreshR2() = runTest { + val harness = matrixHarness("projection-authorization-obsolete-readiness") + assertEquals("seed", harness.engine.get(Freshness.LocalOnly)) + + turbineScope { + val observer = harness.engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + try { + settleInitialProjection(harness, observer) + + val r1Mapping = harness.mappingGate.gateNext() + val r1Write = harness.sourceOfTruth.gateNextWrite() + val heldR1Apply = harness.beforeProjectionApplyGate.gateNext() + harness.overlay.clearCalls() + val apply = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + harness.engine.applyWrite("echo") + } + r1Mapping.entered.await() + r1Write.published.await() + r1Write.releaseReturn.complete(Unit) + apply.await() + heldR1Apply.entered.await() + + val heldReadiness = harness.projectionReadinessGate.gateNext() + r1Mapping.releaseAndAwait() + heldReadiness.entered.await() + harness.engine.confirmFresh(etag = "r2") + heldReadiness.releaseAndAwait() + testScheduler.runCurrent() + observer.expectNoEvents() + + val r2Delivered = harness.afterProjectionDeliveryGate.gateNext() + heldR1Apply.releaseAndAwait() + assertProjectionCall(harness.overlay.awaitCall(), retired = false) + assertProjectionCall(harness.overlay.awaitCall(), retired = false) + r2Delivered.entered.await() + observer.expectNoEvents() + r2Delivered.releaseAndAwait() + + val retirementDelivered = harness.afterProjectionDeliveryGate.gateNext() + harness.overlay.clearCalls() + harness.overlay.retire(harness.key) + assertProjectionCall(harness.overlay.awaitCall(), retired = true) + retirementDelivered.entered.await() + assertLatestConfirmed(observer) + retirementDelivered.releaseAndAwait() + } finally { + harness.releaseAll() + observer.cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun confirmFreshBetweenResolutionAndAuthorization_handsCapturedR1LineageToR2() = runTest { + val harness = matrixHarness("projection-authorization-resolve-factory-race") + assertEquals("seed", harness.engine.get(Freshness.LocalOnly)) + + turbineScope { + val observer = harness.engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + try { + settleInitialProjection(harness, observer) + + val mapping = harness.mappingGate.gateNext() + val delivery = harness.readerDeliveryGate.gateNext() + val write = harness.sourceOfTruth.gateNextWrite() + val residenceProjection = harness.afterProjectionDeliveryGate.gateNext() + harness.overlay.clearCalls() + val apply = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + harness.engine.applyWrite("echo") + } + mapping.entered.await() + write.published.await() + write.releaseReturn.complete(Unit) + apply.await() + assertProjectionCall(harness.overlay.awaitCall(), retired = false) + residenceProjection.entered.await() + residenceProjection.releaseAndAwait() + + val authorization = harness.projectionAuthorizationGate.gateNext() + mapping.releaseAndAwait() + delivery.entered.await() + delivery.releaseAndAwait() + authorization.entered.await() + + harness.engine.confirmFresh(etag = "r2") + authorization.releaseAndAwait() + testScheduler.runCurrent() + observer.expectNoEvents() + + val retirementDelivered = harness.afterProjectionDeliveryGate.gateNext() + harness.overlay.clearCalls() + harness.overlay.retire(harness.key) + assertProjectionCall(harness.overlay.awaitCall(), retired = true) + retirementDelivered.entered.await() + assertLatestConfirmed(observer) + retirementDelivered.releaseAndAwait() + } finally { + harness.releaseAll() + observer.cancelAndIgnoreRemainingEvents() + } + } + } + + private suspend fun TestScope.runSuccessorCell( + harness: MatrixHarness, + observer: ReceiveTurbine>, + heldR2: SequencedGate.Step, + retirementBeforeConfirmFresh: Boolean, + predecessorDeliveredBeforeConfirm: Boolean, + ) { + heldR2.entered.await() + testScheduler.runCurrent() + if (retirementBeforeConfirmFresh && !predecessorDeliveredBeforeConfirm) { + assertPredecessorConfirmed(observer) + } else { + observer.expectNoEvents() + } + + val successorMapping = harness.mappingGate.gateNext() + val successorDelivery = harness.readerDeliveryGate.gateNext() + val successorWrite = harness.sourceOfTruth.gateNextWrite() + harness.overlay.clearCalls() + val successor = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + harness.engine.applyWrite("echo") + } + successorMapping.entered.await() + successorWrite.published.await() + successorWrite.releaseReturn.complete(Unit) + successor.await() + assertProjectionCall( + call = harness.overlay.awaitCall(), + retired = retirementBeforeConfirmFresh, + ) + + if (!retirementBeforeConfirmFresh) { + harness.overlay.clearCalls() + harness.overlay.retire(harness.key) + assertProjectionCall(harness.overlay.awaitCall(), retired = true) + } + testScheduler.runCurrent() + assertFalse(successorMapping.exited.isCompleted) + assertFalse(successorDelivery.entered.isCompleted) + + val heldR3 = harness.beforeProjectionDeliveryGate.gateNext() + val staleR2Delivered = harness.afterProjectionDeliveryGate.gateNext() + heldR2.releaseAndAwait() + staleR2Delivered.entered.await() + observer.expectNoEvents() + staleR2Delivered.releaseAndAwait() + + heldR3.entered.await() + val unauthorizedR3Delivered = harness.afterProjectionDeliveryGate.gateNext() + heldR3.releaseAndAwait() + unauthorizedR3Delivered.entered.await() + observer.expectNoEvents() + unauthorizedR3Delivered.releaseAndAwait() + + assertFalse(successorDelivery.entered.isCompleted) + successorMapping.releaseAndAwait() + testScheduler.runCurrent() + successorDelivery.entered.await() + observer.expectNoEvents() + successorDelivery.releaseAndAwait() + testScheduler.runCurrent() + assertLatestConfirmed(observer) + } + + private suspend fun TestScope.finishWithoutSuccessor( + harness: MatrixHarness, + observer: ReceiveTurbine>, + retirementBeforeConfirmFresh: Boolean, + ) { + if (!retirementBeforeConfirmFresh) { + observer.expectNoEvents() + val retirementDelivered = harness.afterProjectionDeliveryGate.gateNext() + harness.overlay.clearCalls() + harness.overlay.retire(harness.key) + assertProjectionCall(harness.overlay.awaitCall(), retired = true) + retirementDelivered.entered.await() + assertLatestConfirmed(observer) + retirementDelivered.releaseAndAwait() + } else { + testScheduler.runCurrent() + assertLatestConfirmed(observer) + } + } + + private suspend fun TestScope.authorizeR1( + harness: MatrixHarness, + observer: ReceiveTurbine>, + ) { + val mapping = harness.mappingGate.gateNext() + val delivery = harness.readerDeliveryGate.gateNext() + val write = harness.sourceOfTruth.gateNextWrite() + val residenceProjection = harness.afterProjectionDeliveryGate.gateNext() + harness.overlay.clearCalls() + val apply = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + harness.engine.applyWrite("echo") + } + mapping.entered.await() + write.published.await() + write.releaseReturn.complete(Unit) + apply.await() + assertProjectionCall(harness.overlay.awaitCall(), retired = false) + residenceProjection.entered.await() + residenceProjection.releaseAndAwait() + mapping.releaseAndAwait() + delivery.entered.await() + delivery.releaseAndAwait() + testScheduler.runCurrent() + observer.expectNoEvents() + } + + private suspend fun TestScope.settleInitialProjection( + harness: MatrixHarness, + observer: ReceiveTurbine>, + ) { + testScheduler.runCurrent() + val initial = assertIs>(observer.expectMostRecentItem()) + assertEquals("optimistic", initial.value) + assertEquals(Origin.OVERLAY, initial.origin) + harness.sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + harness.overlay.clearCalls() + observer.expectNoEvents() + } + + private fun TestScope.matrixHarness(id: String): MatrixHarness { + val key = TestKey(id) + val sourceOfTruth = MatrixSourceOfTruth() + val overlay = MatrixOverlay() + val mappingGate = SequencedGate() + val readerDeliveryGate = SequencedGate() + val beforeProjectionApplyGate = SequencedGate() + val beforeProjectionDeliveryGate = SequencedGate() + val afterProjectionDeliveryGate = SequencedGate() + val projectionReadinessGate = SequencedGate() + val projectionAuthorizationGate = SequencedGate() + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + error("projection authorization matrix must never fetch") + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = mappingGate::awaitIfQueued, + beforeReaderDeliveryTestGate = readerDeliveryGate::awaitIfQueued, + overlay = overlay, + beforeProjectionApplyTestGate = { beforeProjectionApplyGate.awaitIfQueued() }, + beforeProjectionDeliveryLockTestGate = + beforeProjectionDeliveryGate::awaitIfQueued, + afterProjectionDeliveryTestGate = afterProjectionDeliveryGate::awaitIfQueued, + beforeProjectionReadinessWaitTestGate = + projectionReadinessGate::awaitIfQueued, + beforeProjectionAuthorizationTestGate = + projectionAuthorizationGate::awaitIfQueued, + ) + return MatrixHarness( + key = key, + engine = engine, + sourceOfTruth = sourceOfTruth, + overlay = overlay, + mappingGate = mappingGate, + readerDeliveryGate = readerDeliveryGate, + beforeProjectionApplyGate = beforeProjectionApplyGate, + beforeProjectionDeliveryGate = beforeProjectionDeliveryGate, + afterProjectionDeliveryGate = afterProjectionDeliveryGate, + projectionReadinessGate = projectionReadinessGate, + projectionAuthorizationGate = projectionAuthorizationGate, + ) + } + + private fun assertProjectionCall( + call: MatrixOverlay.ApplyCall, + retired: Boolean, + ) { + assertEquals("echo", call.base) + assertEquals(retired, call.retired) + } + + private fun assertPredecessorConfirmed( + observer: ReceiveTurbine>, + ) { + val data = assertIs>(observer.expectMostRecentItem()) + assertEquals("echo", data.value) + assertEquals(Origin.SOT, data.origin) + } + + private fun assertLatestConfirmed( + observer: ReceiveTurbine>, + ): StoreResult.Data { + val data = assertIs>(observer.expectMostRecentItem()) + assertEquals("echo", data.value) + assertEquals(Origin.SOT, data.origin) + assertFalse(data.isStale) + assertFalse(data.refreshing) + return data + } + + private class MatrixHarness( + val key: TestKey, + val engine: KeyEngine, + val sourceOfTruth: MatrixSourceOfTruth, + val overlay: MatrixOverlay, + val mappingGate: SequencedGate, + val readerDeliveryGate: SequencedGate, + val beforeProjectionApplyGate: SequencedGate, + val beforeProjectionDeliveryGate: SequencedGate, + val afterProjectionDeliveryGate: SequencedGate, + val projectionReadinessGate: SequencedGate, + val projectionAuthorizationGate: SequencedGate, + ) { + fun releaseAll() { + sourceOfTruth.releaseAll() + mappingGate.releaseAll() + readerDeliveryGate.releaseAll() + beforeProjectionApplyGate.releaseAll() + beforeProjectionDeliveryGate.releaseAll() + afterProjectionDeliveryGate.releaseAll() + projectionReadinessGate.releaseAll() + projectionAuthorizationGate.releaseAll() + } + } + + private class SequencedGate { + private val queued = ArrayDeque() + private val allSteps = mutableListOf() + + fun gateNext(): Step = + Step().also { step -> + queued.addLast(step) + allSteps += step + } + + suspend fun awaitIfQueued() { + val step = queued.removeFirstOrNull() ?: return + step.entered.complete(Unit) + try { + step.awaitRelease() + } finally { + step.exited.complete(Unit) + } + } + + fun releaseAll() { + allSteps.forEach { it.requestRelease() } + } + + class Step { + val entered = CompletableDeferred() + private val release = CompletableDeferred() + val exited = CompletableDeferred() + + fun requestRelease() { + release.complete(Unit) + } + + suspend fun releaseAndAwait() { + requestRelease() + exited.await() + } + + suspend fun awaitRelease() { + release.await() + } + } + } + + private class MatrixSourceOfTruth : SingleRowTestSourceOfTruth { + private val liveRows = MutableSharedFlow() + private val queuedWrites = ArrayDeque() + private val allWrites = mutableListOf() + private var readerCalls = 0 + private var current: String? = "seed" + val liveReaderStarted = CompletableDeferred() + + fun gateNextWrite(): WriteStep = + WriteStep().also { step -> + queuedWrites.addLast(step) + allWrites += step + } + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + val isLiveReader = readerCalls >= 2 + return flow { + if (isLiveReader) liveReaderStarted.complete(Unit) + emit(current) + if (isLiveReader) { + liveRows.collect { value -> emit(value) } + } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + val step = + queuedWrites.removeFirstOrNull() + ?: error("Every matrix write must install its deterministic return gate.") + current = value + liveRows.emit(value) + step.published.complete(Unit) + step.releaseReturn.await() + } + + override suspend fun delete(key: TestKey) { + current = null + liveRows.emit(null) + } + + fun releaseAll() { + allWrites.forEach { it.releaseReturn.complete(Unit) } + } + + class WriteStep { + val published = CompletableDeferred() + val releaseReturn = CompletableDeferred() + } + } + + private class MatrixOverlay : Overlay { + private val signals = MutableSharedFlow(replay = 1) + private val calls = Channel(Channel.UNLIMITED) + private var retired = false + + override fun apply( + key: TestKey, + base: String?, + ): String? { + check(calls.trySend(ApplyCall(base = base, retired = retired)).isSuccess) + return if (retired) base else "optimistic" + } + + override val changes: Flow = signals + + suspend fun retire(key: TestKey) { + check(!retired) + retired = true + signals.emit(key) + } + + suspend fun awaitCall(): ApplyCall = calls.receive() + + fun clearCalls() { + while (calls.tryReceive().isSuccess) Unit + } + + data class ApplyCall( + val base: String?, + val retired: Boolean, + ) + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/ProjectionRecomputeVisibilityProbeTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/ProjectionRecomputeVisibilityProbeTest.kt new file mode 100644 index 000000000..7b840b35b --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/ProjectionRecomputeVisibilityProbeTest.kt @@ -0,0 +1,306 @@ +package org.mobilenativefoundation.store6.core.internal + +import app.cash.turbine.test +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.Runnable +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import kotlinx.coroutines.withContext +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.FakeWallClock +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.TestKey +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import org.mobilenativefoundation.store6.core.seam.Overlay +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.seconds + +/** + * Records whether a freshly attached reader receives the previous Ready snapshot while a + * projection recompute is pending for an accepted overlay change, then asserts convergence after + * the recompute completes. + * + * The probe reconstructs the alias-activation handoff's core-visible shape without the + * mutations machinery: an overlay whose projected value changes, a change signal accepted by + * the key's single writer, no residence-revision advance, and the recompute held at the + * engine's own projection-apply gate while a fresh collector attaches. The probe asserts + * convergence. The printed first-frame observations are measurements, not behavioral assertions. + */ +@OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) +class ProjectionRecomputeVisibilityProbeTest { + private val key = TestKey("recompute-visibility-probe") + + @Test + fun freshAttachDuringHeldRecompute_recordFirstFrameThenConverge() = runTest { + val signals = MutableSharedFlow(replay = 1) + val projected = MutableStateFlow("head") + val gateArmed = MutableStateFlow(false) + val gate = SuspendGate() + val overlay = + object : Overlay { + override val changes: Flow = signals + + override fun apply( + key: TestKey, + base: String?, + ): String = projected.value + } + val job = SupervisorJob(backgroundScope.coroutineContext[Job]) + val scope = CoroutineScope(Dispatchers.Default + job) + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { FetcherResult.Success("base") }, + sot = SharedFlowSourceOfTruth(), + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(0L), + engineScope = scope, + telemetry = null, + overlay = overlay, + beforeProjectionApplyTestGate = { if (gateArmed.value) gate.pause() }, + ) + + try { + engine.stream(Freshness.CachedOrFetch).test { + awaitDataValue("head") + cancelAndIgnoreRemainingEvents() + } + println("PROBE032 phase1 initial projection 'head' observed") + withContext(Dispatchers.Default) { + signals.subscriptionCount.first { count -> count > 0 } + } + + projected.value = "head+tail" + gateArmed.value = true + signals.emit(key) + gate.awaitEntered() + println("PROBE032 phase2 recompute held at the apply gate") + + val frames = mutableListOf() + val firstFrame = CompletableDeferred() + val watchdog = + backgroundScope.launch(Dispatchers.Default) { + delay(3_000) + if (!firstFrame.isCompleted) { + println("PROBE032 measurement=NO_FIRST_FRAME_WHILE_HELD (attach waited out the 3s hold)") + gateArmed.value = false + gate.release() + } + } + engine.stream(Freshness.LocalOnly).test { + while (true) { + val item = awaitItem() + if (item is StoreResult.Data) { + frames += item.value + if (!firstFrame.isCompleted) { + firstFrame.complete(item.value) + if (gateArmed.value) { + println("PROBE032 measurement=FIRST_FRAME_WHILE_HELD value=${item.value}") + } + gateArmed.value = false + gate.release() + } + if (item.value == "head+tail") break + } else { + frames += item::class.simpleName.orEmpty() + } + } + cancelAndIgnoreRemainingEvents() + } + watchdog.cancel() + + println("PROBE032 first_frame=${firstFrame.await()}") + println("PROBE032 frames=${frames.joinToString("|")}") + assertEquals("head+tail", frames.last()) + } finally { + job.cancel() + } + } + + @Test + fun freshAttachDuringFrozenWriter_preAcceptanceWindow_recordFirstFrameThenConverge() = runTest { + val signals = MutableSharedFlow(replay = 1) + val projected = MutableStateFlow("head") + val dispatcher = FreezableDispatcher() + val overlay = + object : Overlay { + override val changes: Flow = signals + + override fun apply( + key: TestKey, + base: String?, + ): String = projected.value + } + val job = SupervisorJob(backgroundScope.coroutineContext[Job]) + val scope = CoroutineScope(dispatcher + job) + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { FetcherResult.Success("base") }, + sot = SharedFlowSourceOfTruth(), + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(0L), + engineScope = scope, + telemetry = null, + overlay = overlay, + ) + + try { + engine.stream(Freshness.CachedOrFetch).test { + awaitDataValue("head") + cancelAndIgnoreRemainingEvents() + } + println("PROBE032B phase1 initial projection 'head' observed") + withContext(Dispatchers.Default) { + signals.subscriptionCount.first { count -> count > 0 } + } + withContext(Dispatchers.Default) { delay(200) } + + dispatcher.freeze() + projected.value = "head+tail" + signals.emit(key) + println("PROBE032B phase2 writer frozen pre-acceptance; change emitted; snapshot should still be Ready('head')") + + val frames = mutableListOf() + val firstFrame = CompletableDeferred() + val watchdog = + backgroundScope.launch(Dispatchers.Default) { + delay(3_000) + if (!firstFrame.isCompleted) { + println("PROBE032B measurement=NO_FIRST_FRAME_WHILE_FROZEN (attach waited out the 3s freeze)") + } + dispatcher.thaw() + } + engine.stream(Freshness.LocalOnly).test { + while (true) { + val item = awaitItem() + if (item is StoreResult.Data) { + frames += item.value + if (!firstFrame.isCompleted) { + firstFrame.complete(item.value) + if (dispatcher.isFrozen()) { + println("PROBE032B measurement=FIRST_FRAME_WHILE_FROZEN value=${item.value}") + } + dispatcher.thaw() + } + if (item.value == "head+tail") break + } else { + frames += item::class.simpleName.orEmpty() + } + } + cancelAndIgnoreRemainingEvents() + } + watchdog.cancel() + + println("PROBE032B first_frame=${firstFrame.await()}") + println("PROBE032B frames=${frames.joinToString("|")}") + assertEquals("head+tail", frames.last()) + } finally { + dispatcher.thaw() + job.cancel() + } + } + + private class FreezableDispatcher : kotlinx.coroutines.CoroutineDispatcher() { + private data class DispatcherState( + val frozen: Boolean, + val parked: List>, + ) + + private val state = MutableStateFlow(DispatcherState(frozen = false, parked = emptyList())) + + override fun dispatch( + context: kotlin.coroutines.CoroutineContext, + block: Runnable, + ) { + while (true) { + val current = state.value + if (!current.frozen) { + Dispatchers.Default.dispatch(context, block) + return + } + val next = current.copy(parked = current.parked + (context to block)) + if (state.compareAndSet(current, next)) return + } + } + + fun freeze() { + while (true) { + val current = state.value + if (state.compareAndSet(current, current.copy(frozen = true))) return + } + } + + fun isFrozen(): Boolean = state.value.frozen + + fun thaw() { + while (true) { + val current = state.value + if (state.compareAndSet(current, DispatcherState(frozen = false, parked = emptyList()))) { + current.parked.forEach { entry -> Dispatchers.Default.dispatch(entry.first, entry.second) } + return + } + } + } + } + + private class SuspendGate { + private val entered = CompletableDeferred() + private val released = CompletableDeferred() + + suspend fun pause() { + entered.complete(Unit) + released.await() + } + + suspend fun awaitEntered() { + withContext(Dispatchers.Default) { + entered.await() + } + } + + fun release() { + released.complete(Unit) + } + } + + private suspend fun app.cash.turbine.ReceiveTurbine>.awaitDataValue( + expected: String, + ): StoreResult.Data { + while (true) { + val item = awaitItem() + if (item is StoreResult.Data && item.value == expected) return item + } + } +} + +// Turbine's 3s default would nest inside the 25s shadow. Raising the Turbine deadline above +// the shadow makes runTest the only effective timeout. +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds // strictly > TEST_TIMEOUT: the shadow must fire first + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/ReaderRecordResolutionTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/ReaderRecordResolutionTest.kt new file mode 100644 index 000000000..17a0e37a2 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/ReaderRecordResolutionTest.kt @@ -0,0 +1,137 @@ +package org.mobilenativefoundation.store6.core.internal + +import org.mobilenativefoundation.store6.core.Origin +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame + +class ReaderRecordResolutionTest { + @Test + fun sameGenerationAndRevisionRow_resolvesTheLiveEqualResidence() { + val recorded = envelope("value") + val live = envelope("value") + + val resolved = + resolveCurrentRecord( + record = ReaderRecord.Row(recorded, readerGen = 4L, residenceRevision = 9L), + currentReaderGen = 4L, + currentResidence = live, + currentResidenceRevision = 9L, + ) + + val row = resolved as ReaderRecord.Row + assertSame(live, row.envelope) + assertEquals(9L, row.residenceRevision) + } + + @Test + fun equalContentReplayAtOlderRevision_resolvesTheFresherLiveEnvelope() { + val live = envelope("value") + + val resolved = + resolveCurrentRecord( + record = ReaderRecord.Row(envelope("value"), 4L, 7L), + currentReaderGen = 4L, + currentResidence = live, + currentResidenceRevision = 9L, + ) + + val row = resolved as ReaderRecord.Row + assertSame(live, row.envelope) + assertEquals(9L, row.residenceRevision) + } + + @Test + fun staleRowOrAbsentCannotOverwriteTheCurrentResidence() { + val live = envelope("new") + + assertNull( + resolveCurrentRecord( + record = ReaderRecord.Row(envelope("old"), 2L, 3L), + currentReaderGen = 2L, + currentResidence = live, + currentResidenceRevision = 4L, + ), + ) + assertNull( + resolveCurrentRecord( + record = ReaderRecord.Absent(2L, 3L), + currentReaderGen = 2L, + currentResidence = live, + currentResidenceRevision = 4L, + ), + ) + assertNull( + resolveCurrentRecord( + record = ReaderRecord.Row(envelope("old"), 1L, 3L), + currentReaderGen = 2L, + currentResidence = null, + currentResidenceRevision = 4L, + ), + ) + } + + @Test + fun queuedAbsentBeforeWriterEcho_isRejectedOnceTheLiveRowIsInstalled() { + val live = envelope("writer-echo") + + assertNull( + resolveCurrentRecord( + record = ReaderRecord.Absent(readerGen = 8L, residenceRevision = 10L), + currentReaderGen = 8L, + currentResidence = live, + currentResidenceRevision = 11L, + ), + ) + } + + @Test + fun postReservationRecheck_rejectsConcurrentEqualValueResidenceReplacement() { + val reserved = + ReaderRecord.Row( + envelope = envelope("same-content"), + readerGen = 5L, + residenceRevision = 11L, + ) + val replacement = + ReaderRecord.Row( + envelope = envelope("same-content"), + readerGen = 5L, + residenceRevision = 12L, + ) + + assertFalse(isSameResolvedRow(reserved, replacement)) + } + + @Test + fun memoryOriginOverride_rejectsConcurrentRevisionReplacement() { + val memory = envelope("same-content") + + assertFalse( + canRestampMemoryOrigin( + memoryEnvelope = memory, + memoryRevision = 3L, + currentEnvelope = memory, + currentRevision = 4L, + ), + ) + assertFalse( + canRestampMemoryOrigin( + memoryEnvelope = memory, + memoryRevision = 3L, + currentEnvelope = envelope("same-content"), + currentRevision = 3L, + ), + ) + } + + private fun envelope(value: String): ValueEnvelope = + ValueEnvelope( + value = value, + origin = Origin.SOT, + meta = null, + staleEpochAtCommit = 0L, + ) +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/RotatingSlotSourceOfTruth.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/RotatingSlotSourceOfTruth.kt new file mode 100644 index 000000000..08fcf7ab9 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/RotatingSlotSourceOfTruth.kt @@ -0,0 +1,118 @@ +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth + +/** + * Fault fixture that intentionally violates the source-of-truth reader-liveness contract. + * + * Per-key, namespace, and all destructive deletes rotate matching entries to new null-seeded slots + * without notifying collectors of the old slots. This is reserved for later engine recovery tests + * and must never receive a `SourceOfTruthContractKit` runner. + */ +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +internal class RotatingSlotSourceOfTruth : SourceOfTruth { + private class Slot( + val rows: MutableSharedFlow, + val firstReaderDelivery: CompletableDeferred, + ) + + private class Entry( + var slot: Slot, + var subscriptionCount: Int, + ) + + private val lock = Mutex() + private val entries = HashMap>() + + override fun reader(key: K): Flow = + flow { + val captured = captureSlot(key) + captured.rows.collect { value -> + emit(value) + captured.firstReaderDelivery.complete(Unit) + } + } + + override suspend fun write( + key: K, + value: V, + ) { + val keyId = KeyId.from(key) + lock.withLock { + check(entryFor(keyId).slot.rows.tryEmit(value)) + } + } + + override suspend fun delete(key: K) { + val keyId = KeyId.from(key) + lock.withLock { + entryFor(keyId).slot = newSlot() + } + } + + override suspend fun deleteNamespace(namespace: StoreNamespace) { + lock.withLock { + entries.forEach { (keyId, entry) -> + if (keyId.namespace == namespace.value) { + entry.slot = newSlot() + } + } + } + } + + override suspend fun deleteAll() { + lock.withLock { + entries.values.forEach { entry -> entry.slot = newSlot() } + } + } + + internal suspend fun subscriptionCount(key: K): Int { + val keyId = KeyId.from(key) + return lock.withLock { entries[keyId]?.subscriptionCount ?: 0 } + } + + /** Waits until the current slot's first row has crossed downstream raw-reader capture. */ + internal suspend fun awaitCurrentSlotReaderDelivery(key: K) { + val keyId = KeyId.from(key) + val delivery = lock.withLock { entryFor(keyId).slot.firstReaderDelivery } + delivery.await() + } + + private suspend fun captureSlot(key: K): Slot { + val keyId = KeyId.from(key) + return lock.withLock { + entryFor(keyId).also { entry -> + entry.subscriptionCount += 1 + }.slot + } + } + + private fun entryFor(keyId: KeyId): Entry = + entries.getOrPut(keyId) { + Entry(slot = newSlot(), subscriptionCount = 0) + } + + private fun newSlot(): Slot { + val rows = + MutableSharedFlow( + replay = 1, + extraBufferCapacity = 64, + ) + check(rows.tryEmit(null)) + return Slot( + rows = rows, + firstReaderDelivery = CompletableDeferred(), + ) + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/SharedFlowSourceOfTruth.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/SharedFlowSourceOfTruth.kt new file mode 100644 index 000000000..13fcdcf7c --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/SharedFlowSourceOfTruth.kt @@ -0,0 +1,89 @@ +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth + +/** Reusable SharedFlow-backed source-of-truth fake exercised by the full contract kit. */ +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +internal class SharedFlowSourceOfTruth( + private val beforeBulkEmission: suspend () -> Unit = {}, +) : SourceOfTruth { + private val slotsLock = Mutex() + private val mutationLock = Mutex() + private val slots = HashMap>() + + override fun reader(key: K): Flow = + flow { + emitAll(slotFor(key)) + } + + override suspend fun write( + key: K, + value: V, + ) { + update(key, value) + } + + override suspend fun delete(key: K) { + update(key, null) + } + + override suspend fun deleteNamespace(namespace: StoreNamespace) { + emitNullToSlots { keyId -> keyId.namespace == namespace.value } + } + + override suspend fun deleteAll() { + emitNullToSlots { true } + } + + private suspend fun update( + key: K, + row: V?, + ) { + currentCoroutineContext().ensureActive() + mutationLock.withLock { + withContext(NonCancellable) { + slotFor(key).emit(row) + } + } + } + + private suspend fun slotFor(key: K): MutableSharedFlow { + val keyId = KeyId.from(key) + return slotsLock.withLock { + slots.getOrPut(keyId) { newSlot() } + } + } + + private suspend fun emitNullToSlots(matches: (KeyId) -> Boolean) { + currentCoroutineContext().ensureActive() + mutationLock.withLock { + withContext(NonCancellable) { + val matchingSlots = slotsLock.withLock { slots.filterKeys(matches).toList() } + beforeBulkEmission() + matchingSlots.forEach { (_, slot) -> slot.emit(null) } + } + } + } + + private fun newSlot(): MutableSharedFlow = + MutableSharedFlow( + replay = 1, + extraBufferCapacity = 64, + ).also { slot -> + check(slot.tryEmit(null)) + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/TransitionTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/TransitionTest.kt new file mode 100644 index 000000000..a487d4982 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/TransitionTest.kt @@ -0,0 +1,474 @@ +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.coroutines.CompletableDeferred +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreMeta +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNotSame +import kotlin.test.assertNull +import kotlin.test.assertSame + +@OptIn(ExperimentalStoreApi::class) +class TransitionTest { + private fun ticket(): FetchTicket = FetchTicket(CompletableDeferred()) + + private fun meta( + writtenAtEpochMillis: Long = 10L, + etag: String? = "etag", + ): StoreMeta = EngineStoreMeta(writtenAtEpochMillis, etag) + + private fun commitFetch( + ticket: FetchTicket, + value: Any = "value", + meta: StoreMeta = meta(), + ): KeyEvent.CommitFetch = + KeyEvent.CommitFetch(ticket, value, meta) + + private fun attribution( + owner: FetchTicket = ticket(), + value: Any = "resident", + origin: Origin = Origin.SOT, + meta: StoreMeta = meta(), + staleEpochAtCommit: Long = 2L, + ): AttributionTag = + AttributionTag( + owner = owner, + value = value, + origin = origin, + meta = meta, + staleEpochAtCommit = staleEpochAtCommit, + ) + + @Test + fun attributionTag_equalPayloadInstances_areIdentityDistinct() { + val value = Any() + val meta = meta() + val first = attribution(value = value, meta = meta) + val second = attribution(value = value, meta = meta) + + assertNotSame(first, second) + assertNotEquals(first, second) + } + + @Test + fun ensureFetch_whenIdle_launchesWithFreshTicket() { + val fresh = ticket() + + val result = transition(KeyState.Initial, KeyEvent.EnsureFetch(fresh)) + + assertSame(fresh, assertIs(result.state.fetch).ticket) + assertSame(fresh, assertIs(result.effect).ticket) + } + + @Test + fun ensureFetch_whenInFlight_joinsExistingTicketWithoutChangingState() { + val existing = ticket() + val state = + KeyState.Initial.copy( + fetch = FetchSlot.InFlight(existing, clearEpochAtLaunch = 0L), + ) + + val result = transition(state, KeyEvent.EnsureFetch(ticket())) + + assertSame(state, result.state) + assertSame(existing, assertIs(result.effect).ticket) + } + + @Test + fun settleFetch_withMatchingTicket_returnsToIdle() { + val current = ticket() + val state = + KeyState.Initial.copy( + fetch = FetchSlot.InFlight(current, clearEpochAtLaunch = 2L), + staleEpoch = 1L, + clearEpoch = 2L, + ) + + val result = transition( + state, + KeyEvent.SettleFetch(current), + ) + + assertIs(result.state.fetch) + assertEquals(1L, result.state.staleEpoch) + assertEquals(2L, result.state.clearEpoch) + assertIs(result.effect) + } + + @Test + fun settleFetch_withStaleTicket_preservesCurrentFetch() { + val current = ticket() + val state = + KeyState.Initial.copy( + fetch = FetchSlot.InFlight(current, clearEpochAtLaunch = 0L), + ) + + val result = transition(state, KeyEvent.SettleFetch(ticket())) + + assertSame(state, result.state) + assertIs(result.effect) + } + + @Test + fun settleFetch_whenIdle_isIgnored() { + val result = transition(KeyState.Initial, KeyEvent.SettleFetch(ticket())) + + assertSame(KeyState.Initial, result.state) + assertIs(result.effect) + } + + @Test + fun ensureFetch_recordsClearEpochAtLaunch() { + val state = KeyState.Initial.copy(clearEpoch = 7L) + + val result = transition(state, KeyEvent.EnsureFetch(ticket())) + + assertEquals(7L, assertIs(result.state.fetch).clearEpochAtLaunch) + } + + @Test + fun commitFetch_matchingTicketAndEpoch_commitsAndSettles() { + val current = ticket() + val state = KeyState.Initial.copy( + fetch = FetchSlot.InFlight(current, clearEpochAtLaunch = 0L), + staleEpoch = 3L, + ) + + val result = transition(state, commitFetch(current)) + + assertIs(result.effect) + assertIs(result.state.fetch) + assertEquals(3L, result.state.staleEpoch) // epochs preserved + assertEquals(0L, result.state.clearEpoch) + } + + @Test + fun commitFetch_matchingTicketAndEpoch_stampsValueBoundAttributionAtCommitEpoch() { + val current = ticket() + val value = Any() + val meta = meta(writtenAtEpochMillis = 123L, etag = "v2") + val state = KeyState.Initial.copy( + fetch = FetchSlot.InFlight(current, clearEpochAtLaunch = 4L), + staleEpoch = 7L, + clearEpoch = 4L, + readerGen = 9L, + ) + + val result = transition( + state, + commitFetch( + ticket = current, + value = value, + meta = meta, + ), + ) + + val tag = requireNotNull(result.state.attribution) + assertSame(value, tag.value) + assertEquals(Origin.FETCHER, tag.origin) + assertSame(meta, tag.meta) + assertEquals(7L, tag.staleEpochAtCommit) + assertEquals(9L, result.state.readerGen) + } + + @Test + fun commitFetch_afterClearAdvancedEpoch_isSuperseded() { + val current = ticket() + val state = KeyState.Initial.copy( + fetch = FetchSlot.InFlight(current, clearEpochAtLaunch = 0L), + clearEpoch = 1L, + ) + + val result = transition(state, commitFetch(current)) + + assertIs(result.effect) + assertSame(state, result.state) // slot kept for the settle path + } + + @Test + fun commitFetch_staleTicket_isIgnored() { + val current = ticket() + val state = KeyState.Initial.copy(fetch = FetchSlot.InFlight(current, 0L)) + + val result = transition(state, commitFetch(ticket())) + + assertIs(result.effect) + assertSame(state, result.state) + } + + @Test + fun commitFetch_whenIdle_isIgnored() { + val result = transition(KeyState.Initial, commitFetch(ticket())) + + assertIs(result.effect) + } + + @Test + fun invalidate_bumpsOnlyStaleEpoch_andPreservesReaderGenerationAndAttribution() { + val tag = attribution() + val state = KeyState.Initial.copy(readerGen = 5L, attribution = tag) + + val result = transition(state, KeyEvent.Invalidate) + + assertIs(result.effect) + assertEquals(1L, result.state.staleEpoch) + assertEquals(0L, result.state.clearEpoch) + assertEquals(5L, result.state.readerGen) + assertSame(tag, result.state.attribution) + assertIs(result.state.fetch) + } + + @Test + fun clear_bumpsBothEpochsAndReaderGeneration_revokesAttribution() { + val state = KeyState.Initial.copy( + staleEpoch = 2L, + clearEpoch = 3L, + readerGen = 4L, + attribution = attribution(), + ) + + val result = transition(state, KeyEvent.Clear) + + assertIs(result.effect) + assertEquals(3L, result.state.staleEpoch) + assertEquals(4L, result.state.clearEpoch) + assertEquals(5L, result.state.readerGen) + assertNull(result.state.attribution) + } + + @Test + fun settleFetch_preservesEpochs() { + val current = ticket() + val state = KeyState.Initial.copy( + fetch = FetchSlot.InFlight(current, clearEpochAtLaunch = 2L), + staleEpoch = 5L, + clearEpoch = 2L, + ) + + val result = transition(state, KeyEvent.SettleFetch(current)) + + assertIs(result.state.fetch) + assertEquals(5L, result.state.staleEpoch) // the Initial-reset bug must not return + assertEquals(2L, result.state.clearEpoch) + } + + @Test + fun settleFetch_afterClearAdvancedEpoch_isSupersededAndSettled() { + val current = ticket() + val state = KeyState.Initial.copy( + fetch = FetchSlot.InFlight(current, clearEpochAtLaunch = 1L), + staleEpoch = 4L, + clearEpoch = 2L, + ) + + val result = transition(state, KeyEvent.SettleFetch(current)) + + assertIs(result.effect) + assertIs(result.state.fetch) + assertEquals(4L, result.state.staleEpoch) + assertEquals(2L, result.state.clearEpoch) + } + + @Test + fun settleFetch_afterCommitAlreadySettled_isIgnored() { + val current = ticket() + val committed = transition( + KeyState.Initial.copy(fetch = FetchSlot.InFlight(current, 0L)), + commitFetch(current), + ) + + val result = transition(committed.state, KeyEvent.SettleFetch(current)) + + assertIs(result.effect) + assertSame(committed.state, result.state) + } + + @Test + fun commitDeleted_matchingTicketAndEpoch_settlesAndRequestsDeleteCommit() { + val owner = ticket() + val state = KeyState.Initial.copy( + fetch = FetchSlot.InFlight(owner, clearEpochAtLaunch = 0L), + readerGen = 6L, + attribution = attribution(), + ) + val result = transition(state, KeyEvent.CommitDeleted(owner)) + assertEquals(KeyEffect.CommitDelete, result.effect) + assertEquals(FetchSlot.Idle, result.state.fetch) + assertEquals(1L, result.state.clearEpoch) + assertEquals(0L, result.state.staleEpoch) // no stale bump: deletion must not drive refetch loops + assertEquals(7L, result.state.readerGen) + assertNull(result.state.attribution) + } + + @Test + fun commitDeleted_afterClearAdvancedEpoch_isSuperseded() { + val owner = ticket() + val state = KeyState.Initial.copy( + fetch = FetchSlot.InFlight(owner, clearEpochAtLaunch = 0L), + staleEpoch = 1L, + clearEpoch = 1L, + ) + val result = transition(state, KeyEvent.CommitDeleted(owner)) + assertEquals(KeyEffect.Superseded, result.effect) + assertSame(state, result.state) + } + + @Test + fun commitDeleted_staleTicket_isIgnored() { + val state = KeyState.Initial.copy(fetch = FetchSlot.InFlight(ticket(), clearEpochAtLaunch = 0L)) + val result = transition(state, KeyEvent.CommitDeleted(ticket())) + assertEquals(KeyEffect.Ignored, result.effect) + assertSame(state, result.state) + } + + @Test + fun commitDeleted_whenIdle_isIgnored() { + val result = transition(KeyState.Initial, KeyEvent.CommitDeleted(ticket())) + assertEquals(KeyEffect.Ignored, result.effect) + assertSame(KeyState.Initial, result.state) + } + + @Test + fun commitDeleted_preservesStaleEpochUnderPriorInvalidations() { + val owner = ticket() + val state = KeyState.Initial.copy( + fetch = FetchSlot.InFlight(owner, clearEpochAtLaunch = 2L), + staleEpoch = 5L, + clearEpoch = 2L, + readerGen = 8L, + attribution = attribution(), + ) + val result = transition(state, KeyEvent.CommitDeleted(owner)) + assertEquals(KeyEffect.CommitDelete, result.effect) + assertEquals(5L, result.state.staleEpoch) + assertEquals(3L, result.state.clearEpoch) + assertEquals(9L, result.state.readerGen) + assertNull(result.state.attribution) + } + + @Test + fun commitRevalidated_matchingTicketAndEpoch_settlesAndRevokesAttribution() { + val owner = ticket() + val state = KeyState.Initial.copy( + fetch = FetchSlot.InFlight(owner, clearEpochAtLaunch = 2L), + staleEpoch = 5L, + clearEpoch = 2L, + readerGen = 8L, + attribution = attribution(), + ) + + val result = transition(state, KeyEvent.CommitRevalidated(owner)) + + assertEquals(KeyEffect.CommitRevalidation, result.effect) + assertEquals(FetchSlot.Idle, result.state.fetch) + assertEquals(5L, result.state.staleEpoch) + assertEquals(2L, result.state.clearEpoch) + assertEquals(8L, result.state.readerGen) + assertNull(result.state.attribution) + } + + @Test + fun commitRevalidated_afterClearAdvancedEpoch_isSupersededWithoutRevokingAttribution() { + val owner = ticket() + val tag = attribution() + val state = KeyState.Initial.copy( + fetch = FetchSlot.InFlight(owner, clearEpochAtLaunch = 1L), + clearEpoch = 2L, + attribution = tag, + ) + + val result = transition(state, KeyEvent.CommitRevalidated(owner)) + + assertEquals(KeyEffect.Superseded, result.effect) + assertSame(state, result.state) + assertSame(tag, result.state.attribution) + } + + @Test + fun commitRevalidated_staleTicket_isIgnoredWithoutRevokingAttribution() { + val tag = attribution() + val state = KeyState.Initial.copy( + fetch = FetchSlot.InFlight(ticket(), clearEpochAtLaunch = 0L), + attribution = tag, + ) + + val result = transition(state, KeyEvent.CommitRevalidated(ticket())) + + assertEquals(KeyEffect.Ignored, result.effect) + assertSame(state, result.state) + assertSame(tag, result.state.attribution) + } + + @Test + fun applyWrite_stampsSotAttribution_preservingSlotAndEpochs() { + val inFlight = KeyState.Initial.copy( + fetch = FetchSlot.InFlight(FetchTicket(CompletableDeferred()), 0L), + staleEpoch = 3L, + clearEpoch = 1L, + ) + val meta = EngineStoreMeta(writtenAtEpochMillis = 7L, etag = null) + val writeTicket = FetchTicket(CompletableDeferred()) + + val result = transition( + inFlight, + KeyEvent.ApplyWrite(ticket = writeTicket, value = "v", meta = meta), + ) + + assertEquals(KeyEffect.CommitWrite, result.effect) + assertEquals(inFlight.fetch, result.state.fetch) + assertEquals(3L, result.state.staleEpoch) + assertEquals(1L, result.state.clearEpoch) + val tag = assertNotNull(result.state.attribution) + assertSame(writeTicket, tag.owner) + assertEquals(Origin.SOT, tag.origin) + assertEquals(3L, tag.staleEpochAtCommit) + } + + @Test + fun consumeAttribution_whenPresent_returnsThenClearsTag() { + val tag = attribution() + val state = KeyState.Initial.copy(attribution = tag) + + val result = transition(state, KeyEvent.ConsumeAttribution(tag)) + + assertSame(tag, assertIs(result.effect).tag) + assertNull(result.state.attribution) + } + + @Test + fun consumeAttribution_whenAbsent_returnsNullWithoutChangingStateInstance() { + val state = KeyState.Initial.copy(readerGen = 4L) + + val result = transition(state, KeyEvent.ConsumeAttribution(null)) + + assertNull(assertIs(result.effect).tag) + assertSame(state, result.state) + } + + @Test + fun consumeAttribution_observedBeforeCurrentTag_doesNotConsumeTheLaterTag() { + val later = attribution() + val state = KeyState.Initial.copy(attribution = later) + + val result = transition(state, KeyEvent.ConsumeAttribution(observed = null)) + + assertNull(assertIs(result.effect).tag) + assertSame(state, result.state) + assertSame(later, result.state.attribution) + } + + @Test + fun revokeAttribution_clearsTag() { + val state = KeyState.Initial.copy(attribution = attribution()) + + val result = transition(state, KeyEvent.RevokeAttribution) + + assertEquals(KeyEffect.AttributionRevoked, result.effect) + assertNull(result.state.attribution) + } +} diff --git a/store6-core/src/jsMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.js.kt b/store6-core/src/jsMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.js.kt new file mode 100644 index 000000000..686162571 --- /dev/null +++ b/store6-core/src/jsMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.js.kt @@ -0,0 +1,6 @@ +package org.mobilenativefoundation.store6.core.internal + +import kotlin.js.Date + +/** Returns the JavaScript system wall clock in Unix epoch milliseconds. */ +internal actual fun currentEpochMillis(): Long = Date.now().toLong() diff --git a/store6-core/src/jvmMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.jvm.kt b/store6-core/src/jvmMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.jvm.kt new file mode 100644 index 000000000..87b67fae6 --- /dev/null +++ b/store6-core/src/jvmMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.jvm.kt @@ -0,0 +1,4 @@ +package org.mobilenativefoundation.store6.core.internal + +/** Returns the JVM system wall clock in Unix epoch milliseconds. */ +internal actual fun currentEpochMillis(): Long = System.currentTimeMillis() diff --git a/store6-core/src/jvmTest/kotlin/org/mobilenativefoundation/store6/core/FetcherDispatcherTest.kt b/store6-core/src/jvmTest/kotlin/org/mobilenativefoundation/store6/core/FetcherDispatcherTest.kt new file mode 100644 index 000000000..404289b00 --- /dev/null +++ b/store6-core/src/jvmTest/kotlin/org/mobilenativefoundation/store6/core/FetcherDispatcherTest.kt @@ -0,0 +1,27 @@ +package org.mobilenativefoundation.store6.core + +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertNotSame + +class FetcherDispatcherTest { + @Test + fun fetcherSynchronousWork_runsOffTheCallerThread() = runBlocking { + val callerThread = Thread.currentThread() + lateinit var fetcherThread: Thread + val store = store { + fetcher { + fetcherThread = Thread.currentThread() + "value" + } + } + + try { + store.get(TestKey("1")) + + assertNotSame(callerThread, fetcherThread) + } finally { + store.close() + } + } +} diff --git a/store6-core/src/linuxMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.linux.kt b/store6-core/src/linuxMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.linux.kt new file mode 100644 index 000000000..71e1fbbb5 --- /dev/null +++ b/store6-core/src/linuxMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.linux.kt @@ -0,0 +1,18 @@ +@file:OptIn(ExperimentalForeignApi::class) + +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import platform.posix.gettimeofday +import platform.posix.timeval + +/** Returns the Linux system wall clock in Unix epoch milliseconds. */ +internal actual fun currentEpochMillis(): Long = + memScoped { + val now = alloc() + gettimeofday(now.ptr, null) + (now.tv_sec.toLong() * 1_000L) + (now.tv_usec.toLong() / 1_000L) + } diff --git a/store6-core/src/mingwMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.mingw.kt b/store6-core/src/mingwMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.mingw.kt new file mode 100644 index 000000000..c9cf4e98c --- /dev/null +++ b/store6-core/src/mingwMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.mingw.kt @@ -0,0 +1,21 @@ +@file:OptIn(ExperimentalForeignApi::class) + +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import platform.windows.FILETIME +import platform.windows.GetSystemTimeAsFileTime + +/** Returns the Windows system wall clock in Unix epoch milliseconds. */ +internal actual fun currentEpochMillis(): Long = + memScoped { + val fileTime = alloc() + GetSystemTimeAsFileTime(fileTime.ptr) + val ticks = + (fileTime.dwHighDateTime.toLong() shl 32) or + fileTime.dwLowDateTime.toLong() + (ticks / 10_000L) - 11_644_473_600_000L + } diff --git a/store6-core/src/wasmJsMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.wasmJs.kt b/store6-core/src/wasmJsMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.wasmJs.kt new file mode 100644 index 000000000..267e153cd --- /dev/null +++ b/store6-core/src/wasmJsMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.wasmJs.kt @@ -0,0 +1,6 @@ +package org.mobilenativefoundation.store6.core.internal + +private fun jsDateNow(): Double = js("Date.now()") + +/** Returns the Wasm-JS system wall clock in Unix epoch milliseconds. */ +internal actual fun currentEpochMillis(): Long = jsDateNow().toLong() diff --git a/store6-devtools-demo/README.md b/store6-devtools-demo/README.md new file mode 100644 index 000000000..28f5c4fcb --- /dev/null +++ b/store6-devtools-demo/README.md @@ -0,0 +1,75 @@ +# store6-devtools-demo + +An unpublished reference-app seed for the Store6 devtools experience. It runs the same in-process +inspector on desktop, Android, and iOS. The inspector uses no host tooling or transport: no sockets, +desktop host, or web panel. + +## Desktop + +```shell +./gradlew :store6-devtools-demo:run +``` + +## Android + +Connect an emulator or device, choose its serial explicitly, then build, install, and launch: + +```shell +adb devices +./gradlew :store6-devtools-demo:assembleDebug +ANDROID_SERIAL= ./gradlew :store6-devtools-demo:installDebug +adb -s shell am start -n org.mobilenativefoundation.store6.devtoolsdemo/.MainActivity +``` + +Replace `` with one `device` entry from `adb devices`. Explicit selection avoids installing +or launching against the wrong connected device. + +## iOS + +The Kotlin framework acceptance command is: + +```shell +./gradlew :store6-devtools-demo:linkDebugFrameworkIosSimulatorArm64 +``` + +The committed Xcode host lives under `iosApp/`. Open `iosApp/iosApp.xcodeproj`, select an iOS +simulator, and run scheme `iosApp`. + +To recreate the shell, use these exact settings: + +1. In `store6-devtools-demo/iosApp`, create an iOS App project named `iosApp` with scheme `iosApp`. +2. Set bundle identifier `org.mobilenativefoundation.store6.devtoolsdemo.iosApp` and deployment + target iOS 15. +3. Keep the committed `iosApp/Info.plist`, including + `CADisableMinimumFrameDurationOnPhone` as a Boolean `YES`. +4. In the target build settings for both Debug and Release, set **Generate Info.plist File** to + `No` and **Info.plist File** to `Info.plist`. +5. Add this Run Script build phase **before Compile Sources**: + + ```shell + cd "$SRCROOT/../.." && ./gradlew :store6-devtools-demo:embedAndSignAppleFrameworkForXcode + ``` + +6. Add framework search path + `$(SRCROOT)/../build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)`. +7. Keep the existing `iosApp/iosApp/iOSApp.swift` and + `iosApp/iosApp/ContentView.swift` sources. + +From the repository root, run the exact Xcode acceptance command: + +```shell +cd store6-devtools-demo +xcodebuild -project iosApp/iosApp.xcodeproj -scheme iosApp -sdk iphonesimulator build +``` + +## Android and iOS manual checklist + +Run all six steps on **both** Android and iOS: + +1. Launch the app and open the inspector with the FAB. +2. Confirm the key appears as `FRESH` with its age ticking. +3. Set latency to 3000 ms, tap **Invalidate**, and confirm `STALE` then `FETCHING` plus refreshed + content. +4. Enable failure, tap **Invalidate**, and confirm `ERROR` plus `fetch_failed`. +5. Tap **Clear** and confirm `CLEARED`. +6. Confirm logcat or the Xcode console contains Store6 v0 logger lines. diff --git a/store6-devtools-demo/build.gradle.kts b/store6-devtools-demo/build.gradle.kts new file mode 100644 index 000000000..c490a4f12 --- /dev/null +++ b/store6-devtools-demo/build.gradle.kts @@ -0,0 +1,72 @@ +import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget + +plugins { + id("org.jetbrains.kotlin.multiplatform") + id("com.android.application") + alias(libs.plugins.kotlin.compose.compiler) + alias(libs.plugins.jetbrains.compose) +} + +kotlin { + jvmToolchain(11) + androidTarget() + jvm("desktop") + listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach { target: KotlinNativeTarget -> + target.binaries.framework { + baseName = "DevtoolsDemo" + isStatic = true + } + } + + sourceSets { + val commonMain by getting { + dependencies { + implementation(projects.store6Core) + implementation(projects.store6Testing) + implementation(projects.store6Compose) + implementation(projects.store6Devtools) + implementation(projects.store6DevtoolsInspector) + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + } + } + val androidMain by getting { + dependencies { + // Direct coordinate by design: unpublished demo, catalog untouched. + implementation("androidx.activity:activity-compose:1.10.1") + } + } + val desktopMain by getting { + dependencies { implementation(compose.desktop.currentOs) } + } + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.test) + } + } + } +} + +compose.desktop { + application { + mainClass = "org.mobilenativefoundation.store6.devtoolsdemo.MainKt" + } +} + +android { + namespace = "org.mobilenativefoundation.store6.devtoolsdemo" + compileSdk = 36 + defaultConfig { + applicationId = "org.mobilenativefoundation.store6.devtoolsdemo" + minSdk = 24 + targetSdk = 36 + versionCode = 1 + versionName = "0.1" + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} diff --git a/store6-devtools-demo/iosApp/.gitignore b/store6-devtools-demo/iosApp/.gitignore new file mode 100644 index 000000000..257645df7 --- /dev/null +++ b/store6-devtools-demo/iosApp/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +xcuserdata/ +*.xcuserstate +xcschememanagement.plist diff --git a/store6-devtools-demo/iosApp/Info.plist b/store6-devtools-demo/iosApp/Info.plist new file mode 100644 index 000000000..8c3120bbe --- /dev/null +++ b/store6-devtools-demo/iosApp/Info.plist @@ -0,0 +1,53 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + + UIApplicationSupportsIndirectInputEvents + + UILaunchScreen + + UILaunchScreen + + + UISupportedInterfaceOrientations~iphone + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/store6-devtools-demo/iosApp/iosApp.xcodeproj/project.pbxproj b/store6-devtools-demo/iosApp/iosApp.xcodeproj/project.pbxproj new file mode 100644 index 000000000..49ea45cbe --- /dev/null +++ b/store6-devtools-demo/iosApp/iosApp.xcodeproj/project.pbxproj @@ -0,0 +1,361 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileReference section */ + C93CE112301681D000D2EC83 /* iosApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iosApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + C93CE114301681D000D2EC83 /* iosApp */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = iosApp; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + C93CE10F301681D000D2EC83 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + C93CE109301681D000D2EC83 = { + isa = PBXGroup; + children = ( + C93CE114301681D000D2EC83 /* iosApp */, + C93CE113301681D000D2EC83 /* Products */, + ); + sourceTree = ""; + }; + C93CE113301681D000D2EC83 /* Products */ = { + isa = PBXGroup; + children = ( + C93CE112301681D000D2EC83 /* iosApp.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + C93CE111301681D000D2EC83 /* iosApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = C93CE11D301681D100D2EC83 /* Build configuration list for PBXNativeTarget "iosApp" */; + buildPhases = ( + C93CE1303016848000D2EC83 /* Embed And Sign Apple Framework For Xcode */, + C93CE10E301681D000D2EC83 /* Sources */, + C93CE10F301681D000D2EC83 /* Frameworks */, + C93CE110301681D000D2EC83 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + C93CE114301681D000D2EC83 /* iosApp */, + ); + name = iosApp; + packageProductDependencies = ( + ); + productName = iosApp; + productReference = C93CE112301681D000D2EC83 /* iosApp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + C93CE10A301681D000D2EC83 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2660; + LastUpgradeCheck = 2660; + TargetAttributes = { + C93CE111301681D000D2EC83 = { + CreatedOnToolsVersion = 26.6; + }; + }; + }; + buildConfigurationList = C93CE10D301681D000D2EC83 /* Build configuration list for PBXProject "iosApp" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = C93CE109301681D000D2EC83; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = C93CE113301681D000D2EC83 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + C93CE111301681D000D2EC83 /* iosApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + C93CE110301681D000D2EC83 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + C93CE1303016848000D2EC83 /* Embed And Sign Apple Framework For Xcode */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Embed And Sign Apple Framework For Xcode"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "cd \"$SRCROOT/../..\"\n./gradlew :store6-devtools-demo:embedAndSignAppleFrameworkForXcode\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + C93CE10E301681D000D2EC83 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + C93CE11B301681D100D2EC83 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/../build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)"; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + C93CE11C301681D100D2EC83 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/../build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)"; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + C93CE11E301681D100D2EC83 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.mobilenativefoundation.store6.devtoolsdemo.iosApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + C93CE11F301681D100D2EC83 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.mobilenativefoundation.store6.devtoolsdemo.iosApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + C93CE10D301681D000D2EC83 /* Build configuration list for PBXProject "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C93CE11B301681D100D2EC83 /* Debug */, + C93CE11C301681D100D2EC83 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + C93CE11D301681D100D2EC83 /* Build configuration list for PBXNativeTarget "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C93CE11E301681D100D2EC83 /* Debug */, + C93CE11F301681D100D2EC83 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = C93CE10A301681D000D2EC83 /* Project object */; +} diff --git a/store6-devtools-demo/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/store6-devtools-demo/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/store6-devtools-demo/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/store6-devtools-demo/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json b/store6-devtools-demo/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/store6-devtools-demo/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/store6-devtools-demo/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/store6-devtools-demo/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..230588010 --- /dev/null +++ b/store6-devtools-demo/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/store6-devtools-demo/iosApp/iosApp/Assets.xcassets/Contents.json b/store6-devtools-demo/iosApp/iosApp/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/store6-devtools-demo/iosApp/iosApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/store6-devtools-demo/iosApp/iosApp/ContentView.swift b/store6-devtools-demo/iosApp/iosApp/ContentView.swift new file mode 100644 index 000000000..577edce74 --- /dev/null +++ b/store6-devtools-demo/iosApp/iosApp/ContentView.swift @@ -0,0 +1,10 @@ +import SwiftUI +import DevtoolsDemo + +struct ContentView: UIViewControllerRepresentable { + func makeUIViewController(context: Context) -> UIViewController { + MainViewControllerKt.MainViewController() + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} +} diff --git a/store6-devtools-demo/iosApp/iosApp/iOSApp.swift b/store6-devtools-demo/iosApp/iosApp/iOSApp.swift new file mode 100644 index 000000000..22a927fa3 --- /dev/null +++ b/store6-devtools-demo/iosApp/iosApp/iOSApp.swift @@ -0,0 +1,8 @@ +import SwiftUI + +@main +struct iOSApp: App { + var body: some Scene { + WindowGroup { ContentView() } + } +} diff --git a/store6-devtools-demo/src/androidMain/AndroidManifest.xml b/store6-devtools-demo/src/androidMain/AndroidManifest.xml new file mode 100644 index 000000000..5d8d4c0e9 --- /dev/null +++ b/store6-devtools-demo/src/androidMain/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/store6-devtools-demo/src/androidMain/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/MainActivity.kt b/store6-devtools-demo/src/androidMain/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/MainActivity.kt new file mode 100644 index 000000000..8c39290c4 --- /dev/null +++ b/store6-devtools-demo/src/androidMain/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/MainActivity.kt @@ -0,0 +1,12 @@ +package org.mobilenativefoundation.store6.devtoolsdemo + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { DemoApp() } + } +} diff --git a/store6-devtools-demo/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/DemoApp.kt b/store6-devtools-demo/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/DemoApp.kt new file mode 100644 index 000000000..5633bb781 --- /dev/null +++ b/store6-devtools-demo/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/DemoApp.kt @@ -0,0 +1,154 @@ +@file:OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) + +package org.mobilenativefoundation.store6.devtoolsdemo + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import org.mobilenativefoundation.store6.compose.collectAsStateWithLifecycle +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.seam.Fetcher +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import org.mobilenativefoundation.store6.core.store +import org.mobilenativefoundation.store6.devtools.StoreDevtoolsMonitor +import org.mobilenativefoundation.store6.devtools.StoreTelemetryLogger +import org.mobilenativefoundation.store6.devtools.storeTelemetryOf +import org.mobilenativefoundation.store6.devtools.compose.StoreInspectorOverlay +import org.mobilenativefoundation.store6.testing.FakeFetcher + +class UserKey(val id: String) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("users") + + override fun canonicalId(): String = id +} + +data class User(val id: String, val name: String) + +/** Live knobs the demo screen mutates while the store keeps fetching. */ +class DemoControls { + val latencyMillis = MutableStateFlow(1500L) + val failFetches = MutableStateFlow(false) +} + +/** Toggleable latency/failure around a store6-testing [FakeFetcher]; refetches visibly change. */ +class DemoFetcher( + private val controls: DemoControls, + val delegate: FakeFetcher = FakeFetcher(), +) : Fetcher { + private var version = 0 + + init { + delegate.onUnscripted = { key, _ -> + version += 1 + FetcherResult.Success(User(key.id, "User ${key.id} (v$version)")) + } + } + + override suspend fun fetch(key: UserKey, etag: String?): FetcherResult { + delay(controls.latencyMillis.value) + if (controls.failFetches.value) { + return FetcherResult.Error(IllegalStateException("Demo failure toggle is on")) + } + return delegate.fetch(key, etag) + } +} + +/** Process-wide demo graph: one store, one monitor, ONE builder line installing both sinks. */ +object DemoGraph { + val controls = DemoControls() + val monitor = StoreDevtoolsMonitor() + val users: Store = store { + fetcher(DemoFetcher(controls)) + telemetry(storeTelemetryOf(StoreTelemetryLogger(), monitor)) + } +} + +@Composable +fun DemoApp() { + MaterialTheme { + StoreInspectorOverlay(DemoGraph.monitor) { + DemoScreen(DemoGraph.users, DemoGraph.controls) + } + } +} + +@Composable +private fun DemoScreen(store: Store, controls: DemoControls) { + val scope = rememberCoroutineScope() + val key = remember { UserKey("1") } + val result by store.collectAsStateWithLifecycle(key) + + Column( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + when (val current = result) { + is StoreResult.Data -> Card { + Column(Modifier.padding(16.dp)) { + Text(current.value.name, style = MaterialTheme.typography.headlineSmall) + Text( + "origin=${current.origin} stale=${current.isStale} " + + "refreshing=${current.refreshing}", + ) + } + } + is StoreResult.Loading -> Text("Loading…") + is StoreResult.Error -> Text( + "Error (servedStale=${current.servedStale})", + color = MaterialTheme.colorScheme.error, + ) + is StoreResult.Revalidated -> Text("Revalidated (age ${current.age})") + } + + val latency by controls.latencyMillis.collectAsState() + Text("Fetch latency: ${latency}ms") + Slider( + value = latency.toFloat(), + onValueChange = { controls.latencyMillis.value = it.toLong() }, + modifier = Modifier.semantics { + contentDescription = "Fetch latency in milliseconds" + }, + valueRange = 0f..5000f, + ) + val failing by controls.failFetches.collectAsState() + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Fail fetches") + Switch( + checked = failing, + onCheckedChange = { controls.failFetches.value = it }, + modifier = Modifier.semantics { contentDescription = "Fail fetches" }, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { scope.launch { store.invalidate(key) } }) { Text("Invalidate") } + Button(onClick = { scope.launch { store.clear(key) } }) { Text("Clear") } + } + } +} diff --git a/store6-devtools-demo/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/DemoFetcherTest.kt b/store6-devtools-demo/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/DemoFetcherTest.kt new file mode 100644 index 000000000..79faffcda --- /dev/null +++ b/store6-devtools-demo/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/DemoFetcherTest.kt @@ -0,0 +1,56 @@ +@file:OptIn( + ExperimentalStoreApi::class, + DelicateStoreApi::class, + kotlinx.coroutines.ExperimentalCoroutinesApi::class, +) + +package org.mobilenativefoundation.store6.devtoolsdemo + +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.time.Duration.Companion.seconds + +class DemoFetcherTest { + @Test + fun failureToggleProducesFetcherError(): TestResult = runTest { + val controls = DemoControls().apply { failFetches.value = true } + val fetcher = DemoFetcher(controls) + + assertIs(fetcher.fetch(UserKey("1"), etag = null)) + } + + @Test + fun unscriptedFetchesProduceVersionedUsersAndScriptedResultsWin(): TestResult = runTest { + val controls = DemoControls().apply { latencyMillis.value = 3000L } + val fetcher = DemoFetcher(controls) + val key = UserKey("1") + + val first = fetcher.fetch(key, etag = null) + assertIs>(first) + assertEquals("User 1 (v1)", first.value.name) + assertEquals(3000L, currentTime) + + val second = fetcher.fetch(key, etag = null) + assertIs>(second) + assertEquals("User 1 (v2)", second.value.name) + assertEquals(6000L, currentTime) + + fetcher.delegate.enqueue(key, FetcherResult.Success(User("1", "Scripted"))) + val third = fetcher.fetch(key, etag = null) + assertIs>(third) + assertEquals("Scripted", third.value.name) + assertEquals(9000L, currentTime) + } +} + +// One file-private 25s runTest shadow, no nested wall-clock waits. +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = 25.seconds, testBody = testBody) diff --git a/store6-devtools-demo/src/desktopMain/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/Main.kt b/store6-devtools-demo/src/desktopMain/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/Main.kt new file mode 100644 index 000000000..42f167cc5 --- /dev/null +++ b/store6-devtools-demo/src/desktopMain/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/Main.kt @@ -0,0 +1,7 @@ +package org.mobilenativefoundation.store6.devtoolsdemo + +import androidx.compose.ui.window.singleWindowApplication + +fun main() { + singleWindowApplication(title = "store6 devtools demo") { DemoApp() } +} diff --git a/store6-devtools-demo/src/desktopTest/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/IosAppProjectConfigurationTest.kt b/store6-devtools-demo/src/desktopTest/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/IosAppProjectConfigurationTest.kt new file mode 100644 index 000000000..0fa392367 --- /dev/null +++ b/store6-devtools-demo/src/desktopTest/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/IosAppProjectConfigurationTest.kt @@ -0,0 +1,77 @@ +package org.mobilenativefoundation.store6.devtoolsdemo + +import java.nio.file.Files +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class IosAppProjectConfigurationTest { + @Test + fun targetUsesCommittedInfoPlistWithRequiredComposeBoolean() { + val projectFile = + listOf( + Path.of("iosApp", "iosApp.xcodeproj", "project.pbxproj"), + Path.of("store6-devtools-demo", "iosApp", "iosApp.xcodeproj", "project.pbxproj"), + ).firstOrNull(Files::isRegularFile) + assertNotNull(projectFile, "Could not find the committed iosApp Xcode project") + + val project = Files.readString(projectFile) + val targetConfigurationList = + Regex( + """(?s)[A-F0-9]+ /\* Build configuration list for PBXNativeTarget "iosApp" \*/ = \{\s*""" + + """isa = XCConfigurationList;\s*buildConfigurations = \((.*?)\);""", + ).find(project)?.groupValues?.get(1) + assertNotNull(targetConfigurationList, "Could not find the iosApp target configuration list") + + val configurations = + Regex("""([A-F0-9]+) /\* (Debug|Release) \*/""") + .findAll(targetConfigurationList) + .associate { match -> match.groupValues[2] to match.groupValues[1] } + assertEquals(setOf("Debug", "Release"), configurations.keys) + + configurations.forEach { (name, id) -> + val buildSettings = + Regex( + """(?s)\b$id /\* $name \*/ = \{.*?buildSettings = \{(.*?)""" + + """\n\s*};\n\s*name = $name;""", + ).find(project)?.groupValues?.get(1) + assertNotNull(buildSettings, "Could not find $name iosApp target build settings") + assertTrue( + Regex("""(?m)^\s*GENERATE_INFOPLIST_FILE = NO;\s*$""") + .containsMatchIn(buildSettings), + "$name iosApp target must disable generated Info.plist", + ) + assertTrue( + Regex("""(?m)^\s*INFOPLIST_FILE = Info\.plist;\s*$""") + .containsMatchIn(buildSettings), + "$name iosApp target must use the project-root Info.plist", + ) + } + + val infoPlist = projectFile.parent.parent.resolve("Info.plist") + assertTrue(Files.isRegularFile(infoPlist), "Could not find the committed project-root Info.plist") + val infoPlistContents = Files.readString(infoPlist) + assertTrue( + Regex( + """(?s)\s*CADisableMinimumFrameDurationOnPhone\s*\s*""", + ).containsMatchIn(infoPlistContents), + "iosApp/Info.plist must set CADisableMinimumFrameDurationOnPhone to boolean true", + ) + assertTrue( + Regex( + """(?s)\s*UILaunchScreen\s*\s*\s*""" + + """\s*UILaunchScreen\s*\s*\s*""", + ).containsMatchIn(infoPlistContents), + "iosApp/Info.plist must preserve the generated launch-screen dictionary", + ) + assertTrue( + Regex( + """(?s)\s*UISupportedInterfaceOrientations~iphone\s*\s*.*?""" + + """""", + ).containsMatchIn(infoPlistContents), + "iosApp/Info.plist must preserve the iPhone-specific orientations", + ) + } +} diff --git a/store6-devtools-demo/src/iosMain/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/MainViewController.kt b/store6-devtools-demo/src/iosMain/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/MainViewController.kt new file mode 100644 index 000000000..258409a30 --- /dev/null +++ b/store6-devtools-demo/src/iosMain/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/MainViewController.kt @@ -0,0 +1,6 @@ +package org.mobilenativefoundation.store6.devtoolsdemo + +import androidx.compose.ui.window.ComposeUIViewController +import platform.UIKit.UIViewController + +fun MainViewController(): UIViewController = ComposeUIViewController { DemoApp() } diff --git a/store6-devtools-inspector/README.md b/store6-devtools-inspector/README.md new file mode 100644 index 000000000..423147b35 --- /dev/null +++ b/store6-devtools-inspector/README.md @@ -0,0 +1,72 @@ +# store6-devtools-inspector + +An in-process Compose Multiplatform inspector for a `StoreDevtoolsMonitor`. The published artifact +is `@ExperimentalStoreApi` and reads the monitor's event-derived `StateFlow`; it does not inspect +Store internals. + +## Dependency + +```kotlin +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation( + "org.mobilenativefoundation.store:store6-devtools-inspector:6.0.0-SNAPSHOT", + ) + } + } + } +} +``` + +## Entry points + +```kotlin +import androidx.compose.runtime.Composable +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.devtools.StoreDevtoolsMonitor +import org.mobilenativefoundation.store6.devtools.compose.StoreInspector +import org.mobilenativefoundation.store6.devtools.compose.StoreInspectorOverlay + +@OptIn(ExperimentalStoreApi::class) +@Composable +fun InspectorOnly(monitor: StoreDevtoolsMonitor) { + StoreInspector(monitor) +} + +@OptIn(ExperimentalStoreApi::class) +@Composable +fun AppWithInspector( + monitor: StoreDevtoolsMonitor, + content: @Composable () -> Unit, +) { + StoreInspectorOverlay(monitor = monitor, content = content) +} +``` + +Install the same `monitor` in the Store builder with `telemetry(monitor)`, or combine it with +another sink through `storeTelemetryOf(...)`. `StoreInspector` renders the inspector directly. +`StoreInspectorOverlay` wraps application content with a floating toggle and a lower-half +inspector panel. The two examples are alternative hosts. + +The final target subset is Android, JVM, `iosArm64`, `iosSimulatorArm64`, `iosX64`, +`macosArm64`, JS, and WasmJS. JS uses Node. WasmJS uses a browser test/runtime because the Compose +UI dependency graph is browser-only on this toolchain. No other Store6 targets are published by +this artifact. + +## What it shows + +The current tabs are: + +- **Keys:** namespace, canonical key, derived state, last served origin, and observed-success age. +- **Timeline:** chronological per-key rows labelled with the derived state after that event, the + exact v0 event kind, and elapsed offset. The header shows the key's current derived state and + age. A served event retains the prior row's state; when retained history begins with a serve, + its row is `OBSERVED` because the inspector does not reconstruct dropped history. +- **Events:** the retained event log, newest first, with dropped-event accounting. + +The timeline is a table of telemetry-derived rows, not a freshness chart. It cannot infer policy +staleness or events that were never observed. The tab model leaves room for a future **Outbox** +view; it is not implemented here. Everything stays in process: there are no sockets, host tools, +or web panel. diff --git a/store6-devtools-inspector/api/android/store6-devtools-inspector.api b/store6-devtools-inspector/api/android/store6-devtools-inspector.api new file mode 100644 index 000000000..35363d8bb --- /dev/null +++ b/store6-devtools-inspector/api/android/store6-devtools-inspector.api @@ -0,0 +1,5 @@ +public final class org/mobilenativefoundation/store6/devtools/compose/StoreInspectorKt { + public static final fun StoreInspector (Lorg/mobilenativefoundation/store6/devtools/StoreDevtoolsMonitor;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V + public static final fun StoreInspectorOverlay (Lorg/mobilenativefoundation/store6/devtools/StoreDevtoolsMonitor;Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V +} + diff --git a/store6-devtools-inspector/api/jvm/store6-devtools-inspector.api b/store6-devtools-inspector/api/jvm/store6-devtools-inspector.api new file mode 100644 index 000000000..35363d8bb --- /dev/null +++ b/store6-devtools-inspector/api/jvm/store6-devtools-inspector.api @@ -0,0 +1,5 @@ +public final class org/mobilenativefoundation/store6/devtools/compose/StoreInspectorKt { + public static final fun StoreInspector (Lorg/mobilenativefoundation/store6/devtools/StoreDevtoolsMonitor;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V + public static final fun StoreInspectorOverlay (Lorg/mobilenativefoundation/store6/devtools/StoreDevtoolsMonitor;Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V +} + diff --git a/store6-devtools-inspector/api/store6-devtools-inspector.klib.api b/store6-devtools-inspector/api/store6-devtools-inspector.klib.api new file mode 100644 index 000000000..06dc72197 --- /dev/null +++ b/store6-devtools-inspector/api/store6-devtools-inspector.klib.api @@ -0,0 +1,10 @@ +// Klib ABI Dump +// Targets: [iosArm64, iosSimulatorArm64, iosX64, js, macosArm64, wasmJs] +// Rendering settings: +// - Signature version: 2 +// - Show manifest properties: true +// - Show declarations: true + +// Library unique name: +final fun org.mobilenativefoundation.store6.devtools.compose/StoreInspector(org.mobilenativefoundation.store6.devtools/StoreDevtoolsMonitor, androidx.compose.ui/Modifier?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // org.mobilenativefoundation.store6.devtools.compose/StoreInspector|StoreInspector(org.mobilenativefoundation.store6.devtools.StoreDevtoolsMonitor;androidx.compose.ui.Modifier?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] +final fun org.mobilenativefoundation.store6.devtools.compose/StoreInspectorOverlay(org.mobilenativefoundation.store6.devtools/StoreDevtoolsMonitor, androidx.compose.ui/Modifier?, kotlin/Boolean, kotlin/Function2, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // org.mobilenativefoundation.store6.devtools.compose/StoreInspectorOverlay|StoreInspectorOverlay(org.mobilenativefoundation.store6.devtools.StoreDevtoolsMonitor;androidx.compose.ui.Modifier?;kotlin.Boolean;kotlin.Function2;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] diff --git a/store6-devtools-inspector/build.gradle.kts b/store6-devtools-inspector/build.gradle.kts new file mode 100644 index 000000000..ac0f31f10 --- /dev/null +++ b/store6-devtools-inspector/build.gradle.kts @@ -0,0 +1,63 @@ +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl + +plugins { + id("org.mobilenativefoundation.store.store6.multiplatform.subset") + alias(libs.plugins.kotlin.compose.compiler) + alias(libs.plugins.jetbrains.compose) +} + +// CMP 1.8.2 gates these pinned targets via project-local properties at task-graph time. +extra["org.jetbrains.compose.experimental.macos.enabled"] = true +extra["org.jetbrains.compose.experimental.jscanvas.enabled"] = true + +val store6StabilityConfig = + rootProject.layout.projectDirectory.file("store6-compose/stability/store6-stability.conf") + +composeCompiler { + stabilityConfigurationFiles.add(store6StabilityConfig) +} + +tasks.withType>().configureEach { + inputs.file(store6StabilityConfig).withPathSensitivity(PathSensitivity.RELATIVE) +} + +kotlin { + androidTarget() + jvm() + iosX64() + iosArm64() + iosSimulatorArm64() + macosArm64() + js { nodejs() } + @OptIn(ExperimentalWasmDsl::class) + wasmJs { browser() } + + sourceSets { + val commonMain by getting { + dependencies { + api(projects.store6Devtools) + api(compose.runtime) + api(compose.ui) + implementation(compose.foundation) + implementation(compose.material3) + } + } + val commonTest by getting { + dependencies { + implementation(projects.store6Testing) + implementation(libs.kotlinx.coroutines.test) + } + } + val jvmTest by getting { + dependencies { + @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) + implementation(compose.uiTest) + implementation(compose.desktop.currentOs) + } + } + } +} + +android { + namespace = "org.mobilenativefoundation.store6.devtools.compose" +} diff --git a/store6-devtools-inspector/gradle.properties b/store6-devtools-inspector/gradle.properties new file mode 100644 index 000000000..2af57aacd --- /dev/null +++ b/store6-devtools-inspector/gradle.properties @@ -0,0 +1,3 @@ +VERSION_NAME=6.0.0-SNAPSHOT +POM_NAME=store6-devtools-inspector +POM_ARTIFACT_ID=store6-devtools-inspector diff --git a/multicast/config/ktlint/baseline.xml b/store6-devtools-inspector/src/androidMain/AndroidManifest.xml similarity index 51% rename from multicast/config/ktlint/baseline.xml rename to store6-devtools-inspector/src/androidMain/AndroidManifest.xml index 981420778..8072ee00d 100644 --- a/multicast/config/ktlint/baseline.xml +++ b/store6-devtools-inspector/src/androidMain/AndroidManifest.xml @@ -1,3 +1,2 @@ - - + diff --git a/store6-devtools-inspector/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtools/compose/InspectorState.kt b/store6-devtools-inspector/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtools/compose/InspectorState.kt new file mode 100644 index 000000000..3270a98ee --- /dev/null +++ b/store6-devtools-inspector/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtools/compose/InspectorState.kt @@ -0,0 +1,35 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.devtools.compose + +import org.mobilenativefoundation.store6.devtools.DevtoolsKeyEntry + +/** + * Inspector tabs. + * + * The Phase-2 mutation outbox view lands as a fourth entry here, preserving the planned + * information-architecture slot without affecting the v0 inspector. + */ +internal enum class InspectorTab { + Keys, + Timeline, + Events, +} + +internal data class SelectedKey( + val namespace: String, + val key: String, +) + +internal data class InspectorState( + val tab: InspectorTab = InspectorTab.Keys, + val selected: SelectedKey? = null, +) { + fun withTab(tab: InspectorTab): InspectorState = copy(tab = tab) + + fun withKeySelected(entry: DevtoolsKeyEntry): InspectorState = + copy( + tab = InspectorTab.Timeline, + selected = SelectedKey(entry.namespace, entry.key), + ) +} diff --git a/store6-devtools-inspector/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtools/compose/InspectorUiState.kt b/store6-devtools-inspector/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtools/compose/InspectorUiState.kt new file mode 100644 index 000000000..040b023bd --- /dev/null +++ b/store6-devtools-inspector/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtools/compose/InspectorUiState.kt @@ -0,0 +1,147 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.devtools.compose + +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.devtools.DevtoolsKeyState +import org.mobilenativefoundation.store6.devtools.DevtoolsSnapshot +import org.mobilenativefoundation.store6.devtools.StoreDevtoolsEvent +import kotlin.time.Duration + +internal class KeyRowUi( + val namespace: String, + val key: String, + val stateLabel: String, + val originLabel: String, + val ageLabel: String, + val isSelected: Boolean, +) + +internal class EventRowUi( + val seq: Long, + val atLabel: String, + val kindLabel: String, + val stateLabel: String?, + val keyLabel: String, + val detailLabel: String, +) + +internal class InspectorUiState( + val tab: InspectorTab, + val keyRows: List, + val timelineHeader: String?, + val timelineRows: List, + val eventRows: List, + val dropNotice: String?, + val emptyHint: String?, +) + +internal fun deriveInspectorUiState( + snapshot: DevtoolsSnapshot, + now: Duration, + state: InspectorState, +): InspectorUiState { + val keyRows = snapshot.keys.map { entry -> + KeyRowUi( + namespace = entry.namespace, + key = entry.key, + stateLabel = entry.state.name, + originLabel = entry.lastOrigin?.name ?: "—", + ageLabel = ageLabel(entry.lastFetchSucceededAt, now), + isSelected = state.selected?.let { + it.namespace == entry.namespace && it.key == entry.key + } == true, + ) + } + val selectedEntry = state.selected?.let { selected -> + snapshot.keys.firstOrNull { + it.namespace == selected.namespace && it.key == selected.key + } + } + val timelineRows = state.selected?.let { selected -> + timelineEventRows( + snapshot.events.filter { + it.namespace == selected.namespace && it.key == selected.key + }, + ) + }.orEmpty() + return InspectorUiState( + tab = state.tab, + keyRows = keyRows, + timelineHeader = selectedEntry?.let { + "${it.namespace} / ${it.key} — ${it.state.name}, ${ageLabel(it.lastFetchSucceededAt, now)}" + }, + timelineRows = timelineRows, + eventRows = snapshot.events.asReversed().map(::eventRow), + dropNotice = snapshot.droppedEvents.takeIf { it > 0 } + ?.let { "$it older events dropped" }, + emptyHint = if (snapshot.lastSeq == 0L) { + "No events yet — install with telemetry(monitor) in your store {} builder." + } else { + null + }, + ) +} + +private fun timelineEventRows(events: List): List { + var state = DevtoolsKeyState.OBSERVED + return events.map { event -> + state = when (event) { + is StoreDevtoolsEvent.FetchStarted -> DevtoolsKeyState.FETCHING + is StoreDevtoolsEvent.FetchSucceeded -> DevtoolsKeyState.FRESH + is StoreDevtoolsEvent.FetchFailed -> DevtoolsKeyState.ERROR + is StoreDevtoolsEvent.Served -> state + is StoreDevtoolsEvent.Invalidated -> DevtoolsKeyState.STALE + is StoreDevtoolsEvent.Cleared -> DevtoolsKeyState.CLEARED + } + eventRow(event, state.name) + } +} + +private fun eventRow( + event: StoreDevtoolsEvent, + stateLabel: String? = null, +): EventRowUi { + val (kind, detail) = when (event) { + is StoreDevtoolsEvent.FetchStarted -> "fetch_started" to "" + is StoreDevtoolsEvent.FetchSucceeded -> + "fetch_succeeded" to "fetch_ms=${event.fetchDuration.inWholeMilliseconds}" + is StoreDevtoolsEvent.FetchFailed -> + "fetch_failed" to + "fetch_ms=${event.fetchDuration.inWholeMilliseconds} error=${errorV0Name(event.error)}" + is StoreDevtoolsEvent.Served -> "serve" to "origin=${event.origin.name}" + is StoreDevtoolsEvent.Invalidated -> "invalidate" to "" + is StoreDevtoolsEvent.Cleared -> "clear" to "" + } + return EventRowUi( + seq = event.seq, + atLabel = elapsedLabel(event.at), + kindLabel = kind, + stateLabel = stateLabel, + keyLabel = "${event.namespace}/${event.key}", + detailLabel = detail, + ) +} + +private fun errorV0Name(error: StoreError): String = + when (error) { + is StoreError.Fetch -> "Fetch" + is StoreError.Persistence -> "Persistence" + is StoreError.Conversion -> "Conversion" + is StoreError.FreshnessUnsatisfiable -> "FreshnessUnsatisfiable" + is StoreError.Conflict -> "Conflict" + is StoreError.Missing -> "Missing" + } + +internal fun ageLabel( + anchor: Duration?, + now: Duration, +): String = anchor + ?.let { (now - it).coerceAtLeast(Duration.ZERO) } + ?.let { "age ${elapsedLabel(it)}" } + ?: "age unknown" + +internal fun elapsedLabel(elapsed: Duration): String { + val tenths = elapsed.inWholeMilliseconds / 100 + return "${tenths / 10}.${tenths % 10}s" +} diff --git a/store6-devtools-inspector/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtools/compose/StoreInspector.kt b/store6-devtools-inspector/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtools/compose/StoreInspector.kt new file mode 100644 index 000000000..565a0639f --- /dev/null +++ b/store6-devtools-inspector/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtools/compose/StoreInspector.kt @@ -0,0 +1,188 @@ +package org.mobilenativefoundation.store6.devtools.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.devtools.StoreDevtoolsMonitor +import kotlin.time.Duration.Companion.seconds + +/** + * In-app inspector over a [StoreDevtoolsMonitor]: key browser, per-key timeline, and event log. + * + * State shown here is event-derived; policy staleness (`MaxAge` expiry) emits no event and is + * never inferred. Zero transport: this composable reads the monitor's `StateFlow`, nothing else. + * The telemetry seam it observes remains a freeze candidate. + */ +@ExperimentalStoreApi +@Composable +public fun StoreInspector( + monitor: StoreDevtoolsMonitor, + modifier: Modifier = Modifier, +) { + val snapshot by monitor.state.collectAsState() + var state by remember { mutableStateOf(InspectorState()) } + var now by remember { mutableStateOf(monitor.elapsedNow()) } + LaunchedEffect(monitor) { + while (true) { + now = monitor.elapsedNow() + delay(1.seconds) + } + } + val ui = deriveInspectorUiState(snapshot, now, state) + + Column(modifier.fillMaxSize()) { + TabRow(selectedTabIndex = ui.tab.ordinal) { + InspectorTab.entries.forEach { tab -> + Tab( + selected = ui.tab == tab, + onClick = { state = state.withTab(tab) }, + text = { Text(tab.name) }, + ) + } + } + ui.emptyHint?.let { Text(it, Modifier.padding(16.dp)) } + when (ui.tab) { + InspectorTab.Keys -> LazyColumn(Modifier.fillMaxSize()) { + items( + ui.keyRows, + key = { inspectorItemKey(namespace = it.namespace, key = it.key) }, + ) { row -> + val entry = snapshot.keys.first { + it.namespace == row.namespace && it.key == row.key + } + Row( + Modifier + .fillMaxWidth() + .clickable { state = state.withKeySelected(entry) } + .background( + if (row.isSelected) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surface + }, + ) + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Text("${row.namespace} / ${row.key}", Modifier.weight(1f)) + Text("${row.stateLabel} · ${row.originLabel} · ${row.ageLabel}") + } + } + } + + InspectorTab.Timeline -> Column(Modifier.fillMaxSize()) { + Text( + ui.timelineHeader ?: "Select a key in the Keys tab", + Modifier.padding(12.dp), + style = MaterialTheme.typography.titleSmall, + ) + EventList(ui.timelineRows) + } + + InspectorTab.Events -> Column(Modifier.fillMaxSize()) { + ui.dropNotice?.let { Text(it, Modifier.padding(horizontal = 12.dp)) } + EventList(ui.eventRows) + } + } + } +} + +internal fun inspectorItemKey( + namespace: String, + key: String, +): String = "${namespace.length}:$namespace${key.length}:$key" + +@Composable +private fun EventList(rows: List) { + LazyColumn(Modifier.fillMaxSize()) { + items(rows, key = { it.seq }) { row -> + if (row.stateLabel == null) { + Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp)) { + Text(row.atLabel, Modifier.padding(end = 8.dp)) + Text(row.kindLabel, Modifier.weight(1f)) + Text("${row.keyLabel} ${row.detailLabel}".trim()) + } + } else { + Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp)) { + Text(row.atLabel, Modifier.padding(end = 8.dp)) + Column(Modifier.weight(1f)) { + Row(Modifier.fillMaxWidth()) { + Text(row.stateLabel, Modifier.padding(end = 8.dp)) + Text(row.kindLabel) + } + Text("${row.keyLabel} ${row.detailLabel}".trim()) + } + } + } + } + } +} + +/** + * Wraps app [content] with a floating toggle and [StoreInspector] panel over the lower half. + * + * The overlay is the in-app delivery: no host tooling and no transport. + */ +@ExperimentalStoreApi +@Composable +public fun StoreInspectorOverlay( + monitor: StoreDevtoolsMonitor, + modifier: Modifier = Modifier, + initiallyOpen: Boolean = false, + content: @Composable () -> Unit, +) { + var open by remember { mutableStateOf(initiallyOpen) } + Box(modifier.fillMaxSize()) { + content() + if (open) { + Surface( + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .fillMaxHeight(0.5f), + tonalElevation = 3.dp, + ) { + StoreInspector(monitor) + } + } + FloatingActionButton( + onClick = { open = !open }, + Modifier + .align(Alignment.BottomEnd) + .padding(16.dp) + .semantics { + contentDescription = + if (open) "Close Store6 inspector" else "Open Store6 inspector" + }, + ) { + Text(if (open) "×" else "S6") + } + } +} diff --git a/store6-devtools-inspector/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtools/compose/DeriveInspectorUiStateTest.kt b/store6-devtools-inspector/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtools/compose/DeriveInspectorUiStateTest.kt new file mode 100644 index 000000000..80fdf7229 --- /dev/null +++ b/store6-devtools-inspector/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtools/compose/DeriveInspectorUiStateTest.kt @@ -0,0 +1,318 @@ +/** + * Honesty pin: policy or `MaxAge` staleness emits no event and is not inferred. `FRESH` means only + * that no invalidation, clear, or failure has been observed since the latest success. + */ +@file:OptIn( + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, +) + +package org.mobilenativefoundation.store6.devtools.compose + +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.devtools.StoreDevtoolsMonitor +import org.mobilenativefoundation.store6.testing.TestStoreResults +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TestTimeSource + +class DeriveInspectorUiStateTest { + @Test + fun futureSuccessAnchorClampsOnlyAgeDeltaToZero() { + val clock = TestTimeSource() + val monitor = StoreDevtoolsMonitor(timeSource = clock) + clock += 5.seconds + monitor.onFetchSucceeded(TestKey("users", "user-1"), 120.milliseconds) + + val ui = deriveInspectorUiState( + snapshot = monitor.state.value, + now = 4.seconds, + state = InspectorState(), + ) + + assertEquals("age 0.0s", ui.keyRows.single().ageLabel) + assertEquals("5.0s", ui.eventRows.single().atLabel) + } + + @Test + fun saveableItemKeysDistinguishDelimiterCollisions() { + val first = inspectorItemKey(namespace = "a", key = "b/c") + val second = inspectorItemKey(namespace = "a/b", key = "c") + + assertEquals("1:a3:b/c", first) + assertEquals("3:a/b1:c", second) + assertNotEquals(first, second) + } + + @Test + fun keyRowsExposeEventDerivedStateOriginAndAge() { + val clock = TestTimeSource() + val monitor = StoreDevtoolsMonitor(timeSource = clock) + val fresh = TestKey("users", "user-1") + val unknown = TestKey("users", "user-2") + monitor.onFetchSucceeded(fresh, 120.milliseconds) + monitor.onServe(fresh, Origin.SOT) + monitor.onServe(unknown, Origin.MEMORY) + clock += 2_300.milliseconds + + val ui = deriveInspectorUiState( + snapshot = monitor.state.value, + now = monitor.elapsedNow(), + state = InspectorState(), + ) + + val freshRow = ui.keyRows.single { it.key == "user-1" } + assertEquals("FRESH", freshRow.stateLabel) + assertEquals("SOT", freshRow.originLabel) + assertEquals("age 2.3s", freshRow.ageLabel) + val unknownRow = ui.keyRows.single { it.key == "user-2" } + assertEquals("OBSERVED", unknownRow.stateLabel) + assertEquals("MEMORY", unknownRow.originLabel) + assertEquals("age unknown", unknownRow.ageLabel) + } + + @Test + fun timelineFiltersTheSelectedKeyOldestToNewestWithExactHeader() { + val clock = TestTimeSource() + val monitor = StoreDevtoolsMonitor(timeSource = clock) + val selectedKey = TestKey("users", "user-1") + monitor.onFetchStarted(selectedKey) + clock += 100.milliseconds + monitor.onServe(TestKey("users", "user-2"), Origin.MEMORY) + clock += 100.milliseconds + monitor.onFetchSucceeded(selectedKey, 90.milliseconds) + clock += 2_300.milliseconds + val selectedEntry = monitor.state.value.keys.single { it.key == "user-1" } + + val ui = deriveInspectorUiState( + snapshot = monitor.state.value, + now = monitor.elapsedNow(), + state = InspectorState().withKeySelected(selectedEntry), + ) + + assertEquals("users / user-1 — FRESH, age 2.3s", ui.timelineHeader) + assertEquals(listOf(1L, 3L), ui.timelineRows.map { it.seq }) + assertEquals(listOf("0.0s", "0.2s"), ui.timelineRows.map { it.atLabel }) + assertEquals( + listOf("fetch_started", "fetch_succeeded"), + ui.timelineRows.map { it.kindLabel }, + ) + assertEquals(listOf("FETCHING", "FRESH"), ui.timelineRows.map { it.stateLabel }) + } + + @Test + fun timelineRowsLabelEveryStateChangingEventWithItsDerivedState() { + val monitor = StoreDevtoolsMonitor() + val key = TestKey("users", "user-1") + monitor.onFetchSucceeded(key, 120.milliseconds) + monitor.onInvalidated(key) + monitor.onFetchStarted(key) + monitor.onFetchFailed(key, TestStoreResults.fetchError("offline"), 340.milliseconds) + monitor.onCleared(key) + val selectedEntry = monitor.state.value.keys.single() + + val ui = deriveInspectorUiState( + snapshot = monitor.state.value, + now = monitor.elapsedNow(), + state = InspectorState().withKeySelected(selectedEntry), + ) + + assertEquals( + listOf( + "FRESH", + "STALE", + "FETCHING", + "ERROR", + "CLEARED", + ), + ui.timelineRows.map { it.stateLabel }, + ) + assertEquals( + listOf( + "fetch_ms=120", + "", + "", + "fetch_ms=340 error=Fetch", + "", + ), + ui.timelineRows.map { it.detailLabel }, + ) + assertEquals( + listOf("fetch_succeeded", "invalidate", "fetch_started", "fetch_failed", "clear"), + ui.timelineRows.map { it.kindLabel }, + ) + } + + @Test + fun timelineServedRowsRetainPriorStateOrUseObservedWhenFirst() { + val monitor = StoreDevtoolsMonitor() + val key = TestKey("users", "user-1") + monitor.onServe(key, Origin.MEMORY) + monitor.onInvalidated(TestKey("users", "user-2")) + monitor.onFetchStarted(key) + monitor.onServe(key, Origin.SOT) + monitor.onInvalidated(key) + monitor.onServe(key, Origin.MEMORY) + val selectedEntry = monitor.state.value.keys.single { it.key == "user-1" } + + val ui = deriveInspectorUiState( + snapshot = monitor.state.value, + now = monitor.elapsedNow(), + state = InspectorState().withKeySelected(selectedEntry), + ) + + assertEquals(listOf(1L, 3L, 4L, 5L, 6L), ui.timelineRows.map { it.seq }) + assertEquals( + listOf( + "OBSERVED", + "FETCHING", + "FETCHING", + "STALE", + "STALE", + ), + ui.timelineRows.map { it.stateLabel }, + ) + assertEquals( + listOf("origin=MEMORY", "", "origin=SOT", "", "origin=MEMORY"), + ui.timelineRows.map { it.detailLabel }, + ) + assertEquals( + listOf("serve", "fetch_started", "serve", "invalidate", "serve"), + ui.timelineRows.map { it.kindLabel }, + ) + } + + @Test + fun timelineDoesNotReconstructStateFromHistoryDroppedBeforeItsFirstServe() { + val monitor = StoreDevtoolsMonitor(capacity = 2, timeSource = TestTimeSource()) + val key = TestKey("users", "user-1") + monitor.onFetchSucceeded(key, 120.milliseconds) + monitor.onServe(key, Origin.MEMORY) + monitor.onServe(TestKey("users", "user-2"), Origin.SOT) + val selectedEntry = monitor.state.value.keys.single { it.key == "user-1" } + + val ui = deriveInspectorUiState( + snapshot = monitor.state.value, + now = monitor.elapsedNow(), + state = InspectorState().withKeySelected(selectedEntry), + ) + + assertEquals(1, monitor.state.value.droppedEvents) + assertEquals("users / user-1 — FRESH, age 0.0s", ui.timelineHeader) + assertEquals(listOf(2L), ui.timelineRows.map { it.seq }) + assertEquals(listOf("OBSERVED"), ui.timelineRows.map { it.stateLabel }) + assertEquals(listOf("origin=MEMORY"), ui.timelineRows.map { it.detailLabel }) + } + + @Test + fun eventsAreNewestFirstUseV0KindsAndReportDroppedRows() { + val monitor = StoreDevtoolsMonitor(capacity = 6) + val key = TestKey("users", "user-1") + monitor.onServe(key, Origin.MEMORY) + monitor.onServe(key, Origin.MEMORY) + monitor.onFetchStarted(key) + monitor.onFetchSucceeded(key, 120.milliseconds) + monitor.onFetchFailed(key, TestStoreResults.fetchError("offline"), 340.milliseconds) + monitor.onServe(key, Origin.FETCHER) + monitor.onInvalidated(key) + monitor.onCleared(key) + + val ui = deriveInspectorUiState( + snapshot = monitor.state.value, + now = monitor.elapsedNow(), + state = InspectorState(tab = InspectorTab.Events), + ) + + assertEquals(listOf(8L, 7L, 6L, 5L, 4L, 3L), ui.eventRows.map { it.seq }) + assertEquals( + listOf( + "clear", + "invalidate", + "serve", + "fetch_failed", + "fetch_succeeded", + "fetch_started", + ), + ui.eventRows.map { it.kindLabel }, + ) + assertEquals(emptyList(), ui.eventRows.mapNotNull { it.stateLabel }) + assertEquals("origin=FETCHER", ui.eventRows.single { it.kindLabel == "serve" }.detailLabel) + assertEquals( + "fetch_ms=340 error=Fetch", + ui.eventRows.single { it.kindLabel == "fetch_failed" }.detailLabel, + ) + assertEquals( + "fetch_ms=120", + ui.eventRows.single { it.kindLabel == "fetch_succeeded" }.detailLabel, + ) + assertEquals("2 older events dropped", ui.dropNotice) + } + + @Test + fun fetchFailureDetailsUseTheExhaustiveV0ErrorNames() { + val monitor = StoreDevtoolsMonitor() + val key = TestKey("users", "user-1") + val errors = listOf( + TestStoreResults.fetchError("fetch"), + TestStoreResults.persistenceError("persistence"), + TestStoreResults.conversionError("conversion"), + TestStoreResults.freshnessUnsatisfiable("freshness"), + TestStoreResults.conflict(serverMeta = null, message = "conflict"), + TestStoreResults.missing(key, "missing"), + ) + errors.forEach { monitor.onFetchFailed(key, it, 15.milliseconds) } + + val ui = deriveInspectorUiState( + snapshot = monitor.state.value, + now = monitor.elapsedNow(), + state = InspectorState(tab = InspectorTab.Events), + ) + + assertEquals( + listOf( + "fetch_ms=15 error=Missing", + "fetch_ms=15 error=Conflict", + "fetch_ms=15 error=FreshnessUnsatisfiable", + "fetch_ms=15 error=Conversion", + "fetch_ms=15 error=Persistence", + "fetch_ms=15 error=Fetch", + ), + ui.eventRows.map { it.detailLabel }, + ) + } + + @Test + fun emptyMonitorShowsTheExactInstallHint() { + val monitor = StoreDevtoolsMonitor() + + val ui = deriveInspectorUiState( + snapshot = monitor.state.value, + now = monitor.elapsedNow(), + state = InspectorState(), + ) + + assertEquals( + "No events yet — install with telemetry(monitor) in your store {} builder.", + ui.emptyHint, + ) + assertEquals(emptyList(), ui.keyRows) + assertEquals(emptyList(), ui.eventRows) + assertNull(ui.dropNotice) + assertNull(ui.timelineHeader) + } + + private class TestKey( + namespace: String, + private val id: String, + ) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace(namespace) + + override fun canonicalId(): String = id + } +} diff --git a/store6-devtools-inspector/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtools/compose/InspectorStateTest.kt b/store6-devtools-inspector/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtools/compose/InspectorStateTest.kt new file mode 100644 index 000000000..85e2ec329 --- /dev/null +++ b/store6-devtools-inspector/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtools/compose/InspectorStateTest.kt @@ -0,0 +1,81 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, +) + +package org.mobilenativefoundation.store6.devtools.compose + +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.devtools.StoreDevtoolsMonitor +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Duration + +class InspectorStateTest { + @Test + fun tabSelectionPreservesTheSelectedKey() { + val entry = entry("users", "user-1") + val selected = InspectorState().withKeySelected(entry) + + val events = selected.withTab(InspectorTab.Events) + + assertEquals(InspectorTab.Events, events.tab) + assertEquals(SelectedKey("users", "user-1"), events.selected) + } + + @Test + fun selectingAKeyJumpsToTimelineAndRecordsItsIdentity() { + val selected = InspectorState(tab = InspectorTab.Events) + .withKeySelected(entry("users", "user-1")) + + assertEquals(InspectorTab.Timeline, selected.tab) + assertEquals(SelectedKey("users", "user-1"), selected.selected) + } + + @Test + fun reselectingTheSameKeyIsIdempotent() { + val entry = entry("users", "user-1") + val selectedOnce = InspectorState().withKeySelected(entry) + + val selectedTwice = selectedOnce.withKeySelected(entry) + + assertEquals(selectedOnce, selectedTwice) + } + + @Test + fun selectionSurvivesSnapshotGrowth() { + val monitor = StoreDevtoolsMonitor() + val selectedKey = TestKey("users", "user-1") + monitor.onServe(selectedKey, Origin.MEMORY) + val state = InspectorState().withKeySelected(monitor.state.value.keys.single()) + + monitor.onServe(TestKey("users", "user-2"), Origin.FETCHER) + val ui = deriveInspectorUiState(monitor.state.value, Duration.ZERO, state) + + assertEquals(2, ui.keyRows.size) + assertTrue(ui.keyRows.single { it.key == "user-1" }.isSelected) + assertFalse(ui.keyRows.single { it.key == "user-2" }.isSelected) + assertEquals(SelectedKey("users", "user-1"), state.selected) + } + + private fun entry( + namespace: String, + key: String, + ) = StoreDevtoolsMonitor().run { + onServe(TestKey(namespace, key), Origin.MEMORY) + state.value.keys.single() + } + + private class TestKey( + namespace: String, + private val id: String, + ) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace(namespace) + + override fun canonicalId(): String = id + } +} diff --git a/store6-devtools-inspector/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtools/compose/docs/GuideInspectorSnippet.kt b/store6-devtools-inspector/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtools/compose/docs/GuideInspectorSnippet.kt new file mode 100644 index 000000000..2b94715ea --- /dev/null +++ b/store6-devtools-inspector/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtools/compose/docs/GuideInspectorSnippet.kt @@ -0,0 +1,24 @@ +package org.mobilenativefoundation.store6.devtools.compose.docs + +// docs:snippet:guides-devtools-inspector-hosts +import androidx.compose.runtime.Composable +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.devtools.StoreDevtoolsMonitor +import org.mobilenativefoundation.store6.devtools.compose.StoreInspector +import org.mobilenativefoundation.store6.devtools.compose.StoreInspectorOverlay + +@OptIn(ExperimentalStoreApi::class) +@Composable +fun InspectorOnly(monitor: StoreDevtoolsMonitor) { + StoreInspector(monitor) +} + +@OptIn(ExperimentalStoreApi::class) +@Composable +fun AppWithInspector( + monitor: StoreDevtoolsMonitor, + content: @Composable () -> Unit, +) { + StoreInspectorOverlay(monitor = monitor, content = content) +} +// docs:snippet:end diff --git a/store6-devtools-inspector/src/jvmTest/kotlin/org/mobilenativefoundation/store6/devtools/compose/StoreInspectorSmokeTest.kt b/store6-devtools-inspector/src/jvmTest/kotlin/org/mobilenativefoundation/store6/devtools/compose/StoreInspectorSmokeTest.kt new file mode 100644 index 000000000..eee7b6eff --- /dev/null +++ b/store6-devtools-inspector/src/jvmTest/kotlin/org/mobilenativefoundation/store6/devtools/compose/StoreInspectorSmokeTest.kt @@ -0,0 +1,150 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, +) + +package org.mobilenativefoundation.store6.devtools.compose + +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.dp +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.devtools.StoreDevtoolsMonitor +import org.mobilenativefoundation.store6.testing.TestStoreResults +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds + +@OptIn(ExperimentalTestApi::class) +class StoreInspectorSmokeTest { + @Test + fun inspectorCollectsMonitorUpdatesAfterComposition() = runComposeUiTest { + val monitor = StoreDevtoolsMonitor() + + setContent { StoreInspector(monitor) } + + onAllNodesWithText("users / user-1").assertCountEquals(0) + runOnIdle { + monitor.onFetchStarted(TestKey("users", "user-1")) + monitor.onServe(TestKey("users", "user-1"), Origin.MEMORY) + } + onNodeWithText("users / user-1").assertIsDisplayed() + } + + @Test + fun inspectorRendersBothDelimiterCollisionIdentities() = runComposeUiTest { + val monitor = StoreDevtoolsMonitor() + monitor.onServe(TestKey("a", "b/c"), Origin.MEMORY) + monitor.onServe(TestKey("a/b", "c"), Origin.MEMORY) + + setContent { StoreInspector(monitor) } + + onNodeWithText("a / b/c").assertIsDisplayed() + onNodeWithText("a/b / c").assertIsDisplayed() + } + + @Test + fun timelineRendersEachHistoricalDerivedStateAsAnExactLabel() = runComposeUiTest { + val monitor = StoreDevtoolsMonitor() + val key = TestKey("users", "user-1") + monitor.onFetchSucceeded(key, 1.milliseconds) + monitor.onInvalidated(key) + monitor.onFetchStarted(key) + monitor.onFetchFailed(key, TestStoreResults.fetchError("offline"), 1.milliseconds) + monitor.onCleared(key) + monitor.onFetchSucceeded(key, 1.milliseconds) + + setContent { StoreInspector(monitor) } + + onNodeWithText("users / user-1").performClick() + onNodeWithText("STALE").assertIsDisplayed() + onNodeWithText("FETCHING").assertIsDisplayed() + onNodeWithText("ERROR").assertIsDisplayed() + onNodeWithText("CLEARED").assertIsDisplayed() + } + + @Test + fun compactTimelineKeepsStateAndKindAboveTheKeyDetailLine() = runComposeUiTest { + val monitor = StoreDevtoolsMonitor() + monitor.onInvalidated(TestKey("u", "1")) + + setContent { + StoreInspector( + monitor = monitor, + modifier = Modifier.width(220.dp).height(480.dp), + ) + } + + onNodeWithText("u / 1").performClick() + val state = onNodeWithText("STALE").assertIsDisplayed().fetchSemanticsNode().boundsInRoot + val kind = onNodeWithText("invalidate").assertIsDisplayed().fetchSemanticsNode().boundsInRoot + val keyDetail = onNodeWithText("u/1").assertIsDisplayed().fetchSemanticsNode().boundsInRoot + + assertTrue(kind.top == state.top, "state and exact v0 kind must share the first line") + assertTrue( + keyDetail.top >= maxOf(state.bottom, kind.bottom), + "key/detail must remain visible on a second line at compact widths", + ) + } + + @Test + fun compactEventsKeepTheOriginalWeightedKindAndNeverRenderState() = runComposeUiTest { + val monitor = StoreDevtoolsMonitor() + monitor.onInvalidated(TestKey("u", "1")) + + setContent { + StoreInspector( + monitor = monitor, + modifier = Modifier.width(220.dp).height(480.dp), + ) + } + + onNodeWithText("Events").performClick() + onAllNodesWithText("STALE").assertCountEquals(0) + val kind = onNodeWithText("invalidate").assertIsDisplayed().fetchSemanticsNode().boundsInRoot + val keyDetail = onNodeWithText("u/1").assertIsDisplayed().fetchSemanticsNode().boundsInRoot + + assertTrue( + kind.width > keyDetail.width, + "Events must retain the original weighted-kind, fixed-detail row layout", + ) + } + + @Test + fun overlayFabExposesStateDependentSemanticsAndOpensThePanel() = runComposeUiTest { + val monitor = StoreDevtoolsMonitor() + + setContent { + StoreInspectorOverlay(monitor) { + Text("Host content") + } + } + + onNodeWithContentDescription("Open Store6 inspector") + .assertIsDisplayed() + .performClick() + onNodeWithText("Keys").assertIsDisplayed() + onNodeWithContentDescription("Close Store6 inspector").assertIsDisplayed() + } + + private class TestKey( + namespace: String, + private val id: String, + ) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace(namespace) + + override fun canonicalId(): String = id + } +} diff --git a/store6-devtools/EVENTS.md b/store6-devtools/EVENTS.md new file mode 100644 index 000000000..c05d48386 --- /dev/null +++ b/store6-devtools/EVENTS.md @@ -0,0 +1,90 @@ +# Store6 devtools event vocabulary v0 + +This vocabulary is versioned but **EXPERIMENTAL**. Its names and field order are stable within v0. +The Store 6.1 web-panel wire format will stabilize from this vocabulary and is deliberately +**not** decided here. + +The logger and monitor observe identities and lifecycle facts only. Logger lines and inspector +presentation never include stored values or `StoreError` message and cause payloads. The +in-memory monitor projection does retain the structured `StoreError`; application code holding +`monitor.state` can inspect that object, including its message and cause. + +## Logger line + +Each event is one line with this order: + +```text +

+ + /** Returns the currently parked journal rows. */ + public suspend fun deadLetters(): List +} diff --git a/store6-extension-probe/src/commonTest/kotlin/org/mobilenativefoundation/store6/extensionprobe/CoordinatedTransactionalSourceOfTruthTest.kt b/store6-extension-probe/src/commonTest/kotlin/org/mobilenativefoundation/store6/extensionprobe/CoordinatedTransactionalSourceOfTruthTest.kt new file mode 100644 index 000000000..da6a30bb0 --- /dev/null +++ b/store6-extension-probe/src/commonTest/kotlin/org/mobilenativefoundation/store6/extensionprobe/CoordinatedTransactionalSourceOfTruthTest.kt @@ -0,0 +1,1060 @@ +package org.mobilenativefoundation.store6.extensionprobe + +import app.cash.turbine.ReceiveTurbine +import app.cash.turbine.test +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withContext +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.seam.Overlay +import org.mobilenativefoundation.store6.core.seam.StoreWriteHandle +import org.mobilenativefoundation.store6.core.seam.TransactionalSourceOfTruth +import org.mobilenativefoundation.store6.core.seam.runtime +import org.mobilenativefoundation.store6.core.store +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.test.fail +import kotlin.time.Duration.Companion.seconds + +private data class CoordinatorKey(private val id: String) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("coordinator") + + override fun canonicalId(): String = id +} + +@OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) +private class NoisyTransactionalSourceOfTruth( + initial: String, +) : TransactionalSourceOfTruth { + private data class RowEvent( + val row: String?, + val acknowledgements: Channel?, + ) + + private val rows = MutableSharedFlow(replay = 1) + private val readerStarts = Channel(Channel.UNLIMITED) + private val readerRowsReleased = CompletableDeferred() + private val oldIntermediate = initial + private var current: String = initial + private val committedPendingMutation = MutableStateFlow(true) + private var transactionPendingMutation: Boolean? = null + + val pendingMutation: Boolean + get() = committedPendingMutation.value + var writeCalls: Int = 0 + private set + var retirementCalls: Int = 0 + private set + val rawWriteNotifications: MutableList = mutableListOf() + + init { + check(rows.tryEmit(RowEvent(initial, acknowledgements = null))) + } + + override fun reader(key: CoordinatorKey): Flow = + flow { + readerStarts.send(Unit) + readerRowsReleased.await() + rows.collect { event -> + emit(event.row) + event.acknowledgements?.send(Unit) + } + } + + override suspend fun write( + key: CoordinatorKey, + value: String, + ) { + writeCalls++ + publish(oldIntermediate, recordAsWriteNotification = true) + publish(null, recordAsWriteNotification = true) + current = value + publish(value, recordAsWriteNotification = true) + } + + override suspend fun delete(key: CoordinatorKey) { + error("not used by the probe") + } + + override suspend fun deleteNamespace(namespace: StoreNamespace) { + error("not used by the probe") + } + + override suspend fun deleteAll() { + error("not used by the probe") + } + + override suspend fun withTransaction(block: suspend () -> R): R { + val rowBefore = current + check(transactionPendingMutation == null) { "nested transactions are not supported" } + transactionPendingMutation = committedPendingMutation.value + try { + val result = block() + committedPendingMutation.value = checkNotNull(transactionPendingMutation) + return result + } catch (failure: Throwable) { + withContext(NonCancellable) { + current = rowBefore + publish(current, recordAsWriteNotification = false) + } + throw failure + } finally { + transactionPendingMutation = null + } + } + + suspend fun retireMutation() { + retirementCalls++ + checkNotNull(transactionPendingMutation) { "retirement must run inside a transaction" } + transactionPendingMutation = false + } + + suspend fun publishAuthoritative(value: String) { + current = value + publish(value, recordAsWriteNotification = false) + } + + fun resetWriteObservations() { + writeCalls = 0 + rawWriteNotifications.clear() + } + + suspend fun awaitReaderStarts(expected: Int) { + require(expected > 0) { "expected must be positive" } + repeat(expected) { readerStarts.receive() } + } + + fun releaseReaderRows() { + check(readerRowsReleased.complete(Unit)) { "reader rows were already released" } + } + + private suspend fun publish( + row: String?, + recordAsWriteNotification: Boolean, + ) { + if (recordAsWriteNotification) rawWriteNotifications += row + val expectedAcknowledgements = rows.subscriptionCount.value + val acknowledgements = Channel(Channel.UNLIMITED) + rows.emit(RowEvent(row, acknowledgements)) + repeat(expectedAcknowledgements) { acknowledgements.receive() } + } +} + +@OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) +private class PendingMutationOverlay( + private val source: NoisyTransactionalSourceOfTruth, +) : Overlay { + private val completedApplications = MutableStateFlow(0L) + + val completedApplicationCount: Long + get() = completedApplications.value + + suspend fun awaitApplicationAfter(observed: Long) { + completedApplications.first { it > observed } + } + + override fun apply( + key: CoordinatorKey, + base: String?, + ): String? { + val output = + if (source.pendingMutation) { + base?.let { "$it+pending" } ?: "pending" + } else { + base + } + completedApplications.update { it + 1L } + return output + } + + override val changes: Flow = emptyFlow() +} + +@OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) +private object PendingFreeOverlay : Overlay { + override fun apply( + key: CoordinatorKey, + base: String?, + ): String? = base + + override val changes: Flow = emptyFlow() +} + +@OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) +private object NoOpWriteHandle : StoreWriteHandle { + override suspend fun apply( + key: CoordinatorKey, + value: String, + ) = Unit + + override suspend fun markStale(key: CoordinatorKey) = Unit + + override suspend fun confirmFresh( + key: CoordinatorKey, + etag: String?, + ) = Unit +} + +private enum class AdoptionFailurePoint { + APPLY, + CONFIRM, +} + +@OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) +private class SkewedTransactionalSourceOfTruth( + initial: String, +) : TransactionalSourceOfTruth { + private data class Reader( + val id: Int, + val rows: Channel = Channel(Channel.UNLIMITED), + ) + + private val stateLock = Mutex() + private val readers = mutableListOf() + private var current = initial + private var nextReaderId = 0 + private val slowReaderId = 1 + private val slowReaderOldRowObserved = CompletableDeferred() + private val fastReaderEchoObserved = CompletableDeferred() + private val holdSlowReaderRemainder = CompletableDeferred() + + override fun reader(key: CoordinatorKey): Flow = + flow { + val (reader, firstRow) = + stateLock.withLock { + Reader(nextReaderId++).also(readers::add) to current + } + try { + emit(firstRow) + for (row in reader.rows) { + emit(row) + when { + reader.id == slowReaderId && row == "base" -> { + slowReaderOldRowObserved.complete(Unit) + holdSlowReaderRemainder.await() + } + + reader.id != slowReaderId && row == "echo" -> + fastReaderEchoObserved.complete(Unit) + } + } + } finally { + stateLock.withLock { readers.remove(reader) } + } + } + + override suspend fun write( + key: CoordinatorKey, + value: String, + ) { + val activeReaders = + stateLock.withLock { + current = value + readers.toList() + } + activeReaders.forEach { reader -> + check(reader.rows.trySend("base").isSuccess) + check(reader.rows.trySend(null).isSuccess) + check(reader.rows.trySend(value).isSuccess) + } + } + + override suspend fun delete(key: CoordinatorKey) = error("not used by the regression") + + override suspend fun deleteNamespace(namespace: StoreNamespace) = + error("not used by the regression") + + override suspend fun deleteAll() = error("not used by the regression") + + override suspend fun withTransaction(block: suspend () -> R): R = block() + + suspend fun awaitSkew() { + fastReaderEchoObserved.await() + slowReaderOldRowObserved.await() + } + + fun releaseSlowReader() { + holdSlowReaderRemainder.complete(Unit) + } +} + +@OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) +private class BufferedReaderTransactionalSourceOfTruth( + initial: String, +) : TransactionalSourceOfTruth { + private data class Reader( + val id: Int, + val rows: Channel = Channel(Channel.UNLIMITED), + ) + + private val stateLock = Mutex() + private val readers = mutableListOf() + private val readerRegistrations = Channel(Channel.UNLIMITED) + private var current = initial + private var nextReaderId = 0 + + override fun reader(key: CoordinatorKey): Flow = + flow { + val (reader, firstRow) = + stateLock.withLock { + Reader(nextReaderId++).also(readers::add) to current + } + try { + check(readerRegistrations.trySend(reader.id).isSuccess) + emit(firstRow) + for (row in reader.rows) emit(row) + } finally { + stateLock.withLock { readers.remove(reader) } + } + } + + override suspend fun write( + key: CoordinatorKey, + value: String, + ) { + val activeReaders = + stateLock.withLock { + current = value + readers.toList() + } + activeReaders.forEach { reader -> check(reader.rows.trySend(value).isSuccess) } + } + + override suspend fun delete(key: CoordinatorKey) = error("not used by the regression") + + override suspend fun deleteNamespace(namespace: StoreNamespace) = + error("not used by the regression") + + override suspend fun deleteAll() = error("not used by the regression") + + override suspend fun withTransaction(block: suspend () -> R): R = block() + + suspend fun publishToReader( + readerId: Int, + values: List, + ) { + val reader = stateLock.withLock { readers.single { it.id == readerId } } + values.forEach { value -> check(reader.rows.trySend(value).isSuccess) } + } + + suspend fun publishAuthoritative(value: String) { + val activeReaders = + stateLock.withLock { + current = value + readers.toList() + } + activeReaders.forEach { reader -> check(reader.rows.trySend(value).isSuccess) } + } + + suspend fun awaitReaderRegistration(readerId: Int) { + while (readerRegistrations.receive() != readerId) { + // Fresh generations receive monotonically increasing ids; skip earlier registrations. + } + } +} + +@OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) +class CoordinatedTransactionalSourceOfTruthTest { + @Test + fun retirementVisibility_isPrivateUntilCommit_andRollbackKeepsMutationPending() = runTest { + val delegate = NoisyTransactionalSourceOfTruth(initial = "base") + val retirementStaged = CompletableDeferred() + val holdTransaction = CompletableDeferred() + val transaction = backgroundScope.launch { + delegate.withTransaction { + delegate.retireMutation() + retirementStaged.complete(Unit) + holdTransaction.await() + } + } + + retirementStaged.await() + assertTrue(delegate.pendingMutation) + transaction.cancel(CancellationException("roll back staged retirement")) + transaction.join() + assertTrue(delegate.pendingMutation) + + delegate.withTransaction { + delegate.retireMutation() + assertTrue(delegate.pendingMutation) + } + assertFalse(delegate.pendingMutation) + } + + @Test + fun applyFailure_rethrowsAndReopensWithAuthority_withoutRetirementSignal() = runTest { + val failure = + exerciseFailedAdoption( + failurePoint = AdoptionFailurePoint.APPLY, + failure = IllegalStateException("apply failed"), + ) + + assertEquals("apply failed", assertIs(failure).message) + } + + @Test + fun confirmFailure_rethrowsAndReopensWithAuthority_withoutRetirementSignal() = runTest { + val failure = + exerciseFailedAdoption( + failurePoint = AdoptionFailurePoint.CONFIRM, + failure = IllegalStateException("confirm failed"), + ) + + assertEquals("confirm failed", assertIs(failure).message) + } + + @Test + fun confirmCancellation_cancelsCallerAndReopensWithAuthority_withoutRetirementSignal() = runTest { + val key = CoordinatorKey("confirm-cancelled") + val delegate = BufferedReaderTransactionalSourceOfTruth(initial = "base") + val coordinated = + CoordinatedTransactionalSourceOfTruth(delegate, PendingFreeOverlay) + val rows = Channel(Channel.UNLIMITED) + val reader = backgroundScope.launch { + coordinated.reader(key).collect(rows::send) + } + assertEquals("base", rows.receive()) + val signals = Channel(Channel.UNLIMITED) + val signalReader = backgroundScope.launch { + coordinated.overlay.changes.collect(signals::send) + } + coordinated.awaitRetirementSignalSubscribers(expected = 1) + val confirmReached = CompletableDeferred() + val holdConfirm = CompletableDeferred() + val acknowledgement = backgroundScope.launch { + coordinated.acknowledge( + key = key, + echo = "echo", + etag = null, + handle = + object : StoreWriteHandle { + override suspend fun apply( + key: CoordinatorKey, + value: String, + ) = Unit + + override suspend fun markStale(key: CoordinatorKey) = Unit + + override suspend fun confirmFresh( + key: CoordinatorKey, + etag: String?, + ) { + confirmReached.complete(Unit) + holdConfirm.await() + } + }, + retire = { + coordinated.signalRetired(key) + coordinated.signalRetired(key) + }, + ) + } + confirmReached.await() + acknowledgement.cancel(CancellationException("confirm cancelled")) + acknowledgement.join() + + assertTrue(acknowledgement.isCancelled) + assertEquals("echo", rows.receive()) + testScheduler.runCurrent() + assertTrue(signals.tryReceive().isFailure) + + delegate.publishAuthoritative("later") + assertEquals("later", rows.receive()) + assertTrue(signals.tryReceive().isFailure) + reader.cancel() + signalReader.cancel() + } + + @Test + fun retirementSignal_waitsForDownstreamAuthoritativeRowDelivery() = runTest { + val key = CoordinatorKey("delivery-order") + val delegate = BufferedReaderTransactionalSourceOfTruth(initial = "base") + val coordinated = + CoordinatedTransactionalSourceOfTruth(delegate, PendingFreeOverlay) + val rows = Channel(Channel.UNLIMITED) + val echoCallbackReached = CompletableDeferred() + val allowEchoCallbackToReturn = CompletableDeferred() + val reader = backgroundScope.launch { + coordinated.reader(key).collect { row -> + if (row == "echo") { + echoCallbackReached.complete(Unit) + allowEchoCallbackToReturn.await() + } + rows.send(row) + } + } + assertEquals("base", rows.receive()) + val signals = Channel(Channel.UNLIMITED) + val signalReader = backgroundScope.launch { + coordinated.overlay.changes.collect(signals::send) + } + coordinated.awaitRetirementSignalSubscribers(expected = 1) + val acknowledgement = backgroundScope.launch { + coordinated.acknowledge( + key = key, + echo = "echo", + etag = null, + handle = NoOpWriteHandle, + retire = { + coordinated.signalRetired(key) + coordinated.signalRetired(key) + }, + ) + } + + echoCallbackReached.await() + testScheduler.runCurrent() + try { + assertFalse(acknowledgement.isCompleted) + assertTrue(signals.tryReceive().isFailure) + } finally { + allowEchoCallbackToReturn.complete(Unit) + } + + assertEquals("echo", rows.receive()) + withTimeout(1.seconds) { acknowledgement.join() } + assertEquals(key, signals.receive()) + assertTrue(signals.tryReceive().isFailure) + reader.cancel() + signalReader.cancel() + } + + @Test + fun skewedConcurrentReaders_recaptureTheirOwnAuthoritativeEcho() = runTest { + val key = CoordinatorKey("skew") + val delegate = SkewedTransactionalSourceOfTruth(initial = "base") + val coordinated = + CoordinatedTransactionalSourceOfTruth(delegate, PendingFreeOverlay) + val firstRows = Channel(Channel.UNLIMITED) + val secondRows = Channel(Channel.UNLIMITED) + val firstReader = backgroundScope.launch { + coordinated.reader(key).collect(firstRows::send) + } + assertEquals("base", firstRows.receive()) + val secondReader = backgroundScope.launch { + coordinated.reader(key).collect(secondRows::send) + } + assertEquals("base", secondRows.receive()) + + try { + coordinated.acknowledge( + key = key, + echo = "echo", + etag = null, + handle = + object : StoreWriteHandle { + override suspend fun apply( + key: CoordinatorKey, + value: String, + ) = Unit + + override suspend fun markStale(key: CoordinatorKey) = Unit + + override suspend fun confirmFresh( + key: CoordinatorKey, + etag: String?, + ) { + delegate.awaitSkew() + } + }, + retire = {}, + ) + + assertEquals("echo", firstRows.receive()) + assertEquals("echo", secondRows.receive()) + } finally { + delegate.releaseSlowReader() + firstReader.cancel() + secondReader.cancel() + } + } + + @Test + fun readerCancellationDuringRelease_doesNotStrandAcknowledgementOrGate() = runTest { + val key = CoordinatorKey("cancel-release") + val delegate = BufferedReaderTransactionalSourceOfTruth(initial = "base") + val coordinated = + CoordinatedTransactionalSourceOfTruth(delegate, PendingFreeOverlay) + val healthyRows = Channel(Channel.UNLIMITED) + val healthyReader = backgroundScope.launch { + coordinated.reader(key).collect(healthyRows::send) + } + assertEquals("base", healthyRows.receive()) + val holdSlowReader = CompletableDeferred() + val slowReaderStarted = CompletableDeferred() + val slowReader = backgroundScope.launch { + coordinated.reader(key).collect { + slowReaderStarted.complete(Unit) + holdSlowReader.await() + } + } + slowReaderStarted.await() + delegate.publishToReader(readerId = 1, values = List(100) { "queued-$it" }) + testScheduler.runCurrent() + + val acknowledgement = backgroundScope.launch { + coordinated.acknowledge( + key = key, + echo = "echo", + etag = null, + handle = NoOpWriteHandle, + retire = {}, + ) + } + assertEquals("echo", healthyRows.receive()) + slowReader.cancel() + + withTimeout(1.seconds) { acknowledgement.join() } + assertTrue(acknowledgement.isCompleted) + + val laterRows = Channel(Channel.UNLIMITED) + val laterReader = backgroundScope.launch { + coordinated.reader(key).collect(laterRows::send) + } + assertEquals("echo", laterRows.receive()) + laterReader.cancel() + healthyReader.cancel() + } + + @Test + fun closedGateJoiner_andReleaseBoundaryRow_areDeliveredInAuthoritativeOrder() = runTest { + val key = CoordinatorKey("closed-joiner") + val delegate = BufferedReaderTransactionalSourceOfTruth(initial = "base") + val coordinated = + CoordinatedTransactionalSourceOfTruth(delegate, PendingFreeOverlay) + val originalRows = Channel(Channel.UNLIMITED) + val originalReader = backgroundScope.launch { + coordinated.reader(key).collect(originalRows::send) + } + assertEquals("base", originalRows.receive()) + delegate.awaitReaderRegistration(readerId = 0) + val holdSlowReader = CompletableDeferred() + val slowReaderStarted = CompletableDeferred() + val slowReader = backgroundScope.launch { + coordinated.reader(key).collect { + slowReaderStarted.complete(Unit) + holdSlowReader.await() + } + } + slowReaderStarted.await() + delegate.awaitReaderRegistration(readerId = 1) + delegate.publishToReader(readerId = 1, values = List(100) { "queued-$it" }) + testScheduler.runCurrent() + val confirmReached = CompletableDeferred() + val allowConfirm = CompletableDeferred() + val acknowledgement = backgroundScope.launch { + coordinated.acknowledge( + key = key, + echo = "echo", + etag = null, + handle = + object : StoreWriteHandle { + override suspend fun apply( + key: CoordinatorKey, + value: String, + ) = Unit + + override suspend fun markStale(key: CoordinatorKey) = Unit + + override suspend fun confirmFresh( + key: CoordinatorKey, + etag: String?, + ) { + confirmReached.complete(Unit) + allowConfirm.await() + } + }, + retire = {}, + ) + } + confirmReached.await() + + val joiningRows = Channel(Channel.UNLIMITED) + val joiningReader = backgroundScope.launch { + coordinated.reader(key).collect(joiningRows::send) + } + delegate.awaitReaderRegistration(readerId = 2) + testScheduler.runCurrent() + assertTrue(joiningRows.tryReceive().isFailure) + + allowConfirm.complete(Unit) + assertEquals("echo", originalRows.receive()) + assertEquals("echo", joiningRows.receive()) + assertFalse(acknowledgement.isCompleted) + + delegate.publishAuthoritative("later") + testScheduler.runCurrent() + + assertEquals("later", originalRows.receive()) + assertEquals("later", joiningRows.receive()) + assertFalse(acknowledgement.isCompleted) + + holdSlowReader.complete(Unit) + withTimeout(1.seconds) { acknowledgement.join() } + assertTrue(acknowledgement.isCompleted) + originalReader.cancel() + joiningReader.cancel() + slowReader.cancel() + } + + @Test + fun acknowledge_gatesBothWrites_thenReleasesLatestRowBeforeOneRetirementSignal() = runTest { + val key = CoordinatorKey("1") + val delegate = NoisyTransactionalSourceOfTruth(initial = "base") + val overlay = PendingMutationOverlay(delegate) + val coordinated = CoordinatedTransactionalSourceOfTruth(delegate, overlay) + var fetches = 0 + val store = store { + fetcher { + fetches++ + "network" + } + persistence(coordinated) + overlay(coordinated.overlay) + } + + try { + val realHandle = checkNotNull(store.runtime()).writeHandle + realHandle.apply(key, "base") + realHandle.confirmFresh(key, etag = "seed") + val localValues = Channel(Channel.UNLIMITED) + val localCollector = backgroundScope.launch { + store.stream(key, Freshness.LocalOnly).collect { result -> + if (result is StoreResult.Data) localValues.send(result.value) + } + } + assertEquals("base+pending", localValues.receive()) + delegate.awaitReaderStarts(expected = 1) + val beforeReplay = overlay.completedApplicationCount + delegate.releaseReaderRows() + overlay.awaitApplicationAfter(beforeReplay) + delegate.resetWriteObservations() + val fetchesBeforeAcknowledge = fetches + + store.stream(key, Freshness.CachedOrFetch).test { + assertEquals("base+pending", awaitData().value) + + val downstreamRows = mutableListOf() + val rowDeliveries = Channel(Channel.UNLIMITED) + val chronology = mutableListOf() + val readerJob = backgroundScope.launch { + coordinated.reader(key).collect { row -> + downstreamRows += row + chronology += "row:$row" + rowDeliveries.send(row) + } + } + assertEquals("base", rowDeliveries.receive()) + chronology.clear() + + val signalDeliveries = Channel(Channel.UNLIMITED) + coordinated.awaitRetirementSignalSubscribers(expected = 1) + val signalJob = backgroundScope.launch { + coordinated.overlay.changes.collect { signaledKey -> + chronology += "signal" + signalDeliveries.send(signaledKey) + } + } + coordinated.awaitRetirementSignalSubscribers(expected = 2) + val observingHandle = + object : StoreWriteHandle { + override suspend fun apply( + key: CoordinatorKey, + value: String, + ) { + chronology += "apply:start" + assertEquals(listOf("base"), downstreamRows) + assertTrue(signalDeliveries.tryReceive().isFailure) + realHandle.apply(key, value) + assertEquals(listOf("base"), downstreamRows) + chronology += "apply:end" + } + + override suspend fun markStale(key: CoordinatorKey) { + realHandle.markStale(key) + } + + override suspend fun confirmFresh( + key: CoordinatorKey, + etag: String?, + ) { + chronology += "confirm:start" + assertEquals(listOf("base"), downstreamRows) + assertTrue(signalDeliveries.tryReceive().isFailure) + realHandle.confirmFresh(key, etag) + assertEquals(listOf("base"), downstreamRows) + chronology += "confirm:end" + } + } + + coordinated.acknowledge( + key = key, + echo = "echo", + etag = "etag-1", + handle = observingHandle, + retire = { + chronology += "retire" + delegate.retireMutation() + coordinated.signalRetired(key) + coordinated.signalRetired(key) + }, + ) + testScheduler.runCurrent() + + assertEquals("echo", awaitData().value) + expectNoEvents() + assertEquals(key, signalDeliveries.receive()) + assertTrue(signalDeliveries.tryReceive().isFailure) + assertEquals(fetchesBeforeAcknowledge, fetches) + assertEquals(2, delegate.writeCalls) + assertEquals( + listOf("base", null, "echo", "base", null, "echo"), + delegate.rawWriteNotifications, + ) + assertEquals(1, delegate.retirementCalls) + assertFalse(delegate.pendingMutation) + assertEquals(listOf("base", "echo"), downstreamRows) + assertEquals( + listOf( + "retire", + "apply:start", + "apply:end", + "confirm:start", + "confirm:end", + "row:echo", + "signal", + ), + chronology, + ) + localCollector.cancel() + signalJob.cancel() + readerJob.cancel() + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun cancellationRollback_discardsRowsAndSignal_recapturesAuthority_andReopensGate() = runTest { + val key = CoordinatorKey("rollback") + val delegate = NoisyTransactionalSourceOfTruth(initial = "base") + val overlay = PendingMutationOverlay(delegate) + val coordinated = CoordinatedTransactionalSourceOfTruth(delegate, overlay) + var fetches = 0 + val store = store { + fetcher { + fetches++ + "network" + } + persistence(coordinated) + overlay(coordinated.overlay) + } + + try { + val realHandle = checkNotNull(store.runtime()).writeHandle + realHandle.apply(key, "base") + realHandle.confirmFresh(key, etag = "seed") + val localValues = Channel(Channel.UNLIMITED) + val localCollector = backgroundScope.launch { + store.stream(key, Freshness.LocalOnly).collect { result -> + if (result is StoreResult.Data) localValues.send(result.value) + } + } + assertEquals("base+pending", localValues.receive()) + delegate.awaitReaderStarts(expected = 1) + val beforeReplay = overlay.completedApplicationCount + delegate.releaseReaderRows() + overlay.awaitApplicationAfter(beforeReplay) + delegate.resetWriteObservations() + val fetchesBeforeAcknowledge = fetches + + val downstreamRows = mutableListOf() + val rowDeliveries = Channel(Channel.UNLIMITED) + val readerJob = backgroundScope.launch { + coordinated.reader(key).collect { row -> + downstreamRows += row + rowDeliveries.send(row) + } + } + assertEquals("base", rowDeliveries.receive()) + + val signalDeliveries = Channel(Channel.UNLIMITED) + coordinated.awaitRetirementSignalSubscribers(expected = 1) + val signalJob = backgroundScope.launch { + coordinated.overlay.changes.collect(signalDeliveries::send) + } + coordinated.awaitRetirementSignalSubscribers(expected = 2) + + store.stream(key, Freshness.CachedOrFetch).test { + assertEquals("base+pending", awaitData().value) + var applyCalls = 0 + var confirmCalls = 0 + val unusedHandle = + object : StoreWriteHandle { + override suspend fun apply( + key: CoordinatorKey, + value: String, + ) { + applyCalls++ + } + + override suspend fun markStale(key: CoordinatorKey) = Unit + + override suspend fun confirmFresh( + key: CoordinatorKey, + etag: String?, + ) { + confirmCalls++ + } + } + + val retireReached = CompletableDeferred() + val holdRetire = CompletableDeferred() + val acknowledgement = backgroundScope.launch { + coordinated.acknowledge( + key = key, + echo = "echo", + etag = null, + handle = unusedHandle, + retire = { + delegate.retireMutation() + coordinated.signalRetired(key) + coordinated.signalRetired(key) + retireReached.complete(Unit) + holdRetire.await() + }, + ) + } + retireReached.await() + acknowledgement.cancel(CancellationException("cancel during transaction")) + acknowledgement.join() + testScheduler.runCurrent() + + assertTrue(acknowledgement.isCancelled) + assertEquals("base", rowDeliveries.receive()) + assertEquals(listOf("base", "base"), downstreamRows) + assertEquals(listOf("base", null, "echo"), delegate.rawWriteNotifications) + assertEquals(1, delegate.writeCalls) + assertEquals(1, delegate.retirementCalls) + assertTrue(delegate.pendingMutation) + assertEquals(0, applyCalls) + assertEquals(0, confirmCalls) + assertTrue(signalDeliveries.tryReceive().isFailure) + expectNoEvents() + assertEquals(fetchesBeforeAcknowledge, fetches) + cancelAndIgnoreRemainingEvents() + } + + localCollector.cancel() + delegate.publishAuthoritative("later") + testScheduler.runCurrent() + + assertEquals("later", rowDeliveries.receive()) + assertEquals(listOf("base", "base", "later"), downstreamRows) + signalJob.cancel() + readerJob.cancel() + } finally { + store.close() + } + } + + private suspend fun ReceiveTurbine>.awaitData(): StoreResult.Data { + while (true) { + val item = awaitItem() + if (item is StoreResult.Data) return item + assertIs(item) + } + } + + private suspend fun TestScope.exerciseFailedAdoption( + failurePoint: AdoptionFailurePoint, + failure: Throwable, + ): Throwable { + val key = CoordinatorKey("failed-${failurePoint.name.lowercase()}") + val delegate = BufferedReaderTransactionalSourceOfTruth(initial = "base") + val coordinated = + CoordinatedTransactionalSourceOfTruth(delegate, PendingFreeOverlay) + val rows = Channel(Channel.UNLIMITED) + val reader = backgroundScope.launch { + coordinated.reader(key).collect(rows::send) + } + assertEquals("base", rows.receive()) + val signals = Channel(Channel.UNLIMITED) + val signalReader = backgroundScope.launch { + coordinated.overlay.changes.collect(signals::send) + } + coordinated.awaitRetirementSignalSubscribers(expected = 1) + val handle = + object : StoreWriteHandle { + override suspend fun apply( + key: CoordinatorKey, + value: String, + ) { + if (failurePoint == AdoptionFailurePoint.APPLY) throw failure + } + + override suspend fun markStale(key: CoordinatorKey) = Unit + + override suspend fun confirmFresh( + key: CoordinatorKey, + etag: String?, + ) { + if (failurePoint == AdoptionFailurePoint.CONFIRM) throw failure + } + } + + val thrown = + try { + coordinated.acknowledge( + key = key, + echo = "echo", + etag = null, + handle = handle, + retire = { + coordinated.signalRetired(key) + coordinated.signalRetired(key) + }, + ) + fail("acknowledgement should propagate the adoption failure") + } catch (caught: Throwable) { + caught + } + + assertEquals("echo", rows.receive()) + testScheduler.runCurrent() + assertTrue(signals.tryReceive().isFailure) + + delegate.publishAuthoritative("later") + assertEquals("later", rows.receive()) + assertTrue(signals.tryReceive().isFailure) + reader.cancel() + signalReader.cancel() + return thrown + } +} diff --git a/store6-extension-probe/src/commonTest/kotlin/org/mobilenativefoundation/store6/extensionprobe/ExtensionProbeTest.kt b/store6-extension-probe/src/commonTest/kotlin/org/mobilenativefoundation/store6/extensionprobe/ExtensionProbeTest.kt new file mode 100644 index 000000000..42e34d27c --- /dev/null +++ b/store6-extension-probe/src/commonTest/kotlin/org/mobilenativefoundation/store6/extensionprobe/ExtensionProbeTest.kt @@ -0,0 +1,99 @@ +package org.mobilenativefoundation.store6.extensionprobe + +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.seam.StoreResults +import org.mobilenativefoundation.store6.core.seam.runtime +import org.mobilenativefoundation.store6.core.store +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration + +private class ProbeKey(private val id: String) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("probe") + + override fun canonicalId(): String = id +} + +@OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) +class ExtensionProbeTest { + @Test + fun factories_constructEveryResultState_withoutInternals() { + val data = + StoreResults.data( + value = "v", + origin = Origin.OVERLAY, + age = Duration.ZERO, + isStale = true, + refreshing = true, + ) + assertEquals("v", data.value) + assertEquals(Origin.OVERLAY, data.origin) + StoreResults.loading() + assertEquals(Duration.ZERO, StoreResults.revalidated(Duration.ZERO).age) + val error = + StoreResults.error( + StoreResults.missing(ProbeKey("1"), "absent"), + servedStale = true, + ) + assertTrue(error.servedStale) + assertEquals("m", StoreResults.fetchError("m").message) + assertEquals("m", StoreResults.persistenceError("m").message) + assertEquals("m", StoreResults.conversionError("m").message) + assertEquals("m", StoreResults.freshnessUnsatisfiable("m").message) + val conflict = StoreResults.conflict(serverMeta = null, message = "m") + assertEquals("m", conflict.message) + assertNull(conflict.serverMeta) + val missing = StoreResults.missing(ProbeKey("k"), "m") + assertEquals("k", missing.key.canonicalId()) + assertEquals("m", missing.message) + assertEquals("m", StoreResults.exception(StoreResults.fetchError("m")).message) + } + + @Test + fun metricsDecorator_composesSeamOnly() = runTest { + val metrics = MetricsTelemetry() + val store = store { + fetcher { "v" } + telemetry(metrics) + } + val logs = mutableListOf() + val logging = LoggingStore(store, logs::add) + + assertEquals("v", logging.get(ProbeKey("1"))) + + // Fetch telemetry runs before the ticket completes, so get() returning is the causal + // barrier for these exact counters. + assertEquals(1, metrics.fetchStarts) + assertEquals(1, metrics.fetchSuccesses) + assertEquals(1, metrics.serves) + assertNull(logging.runtime()) + assertNotNull(store.runtime()) + store.close() + } + + @Test + fun mutationDrainVocabulary_isTypedAndExtensionOwned() { + val recorded = mutableListOf() + val sink = MutationEventSink(recorded::add) + val failure = + MutationDrainEvent.Failed( + mutationId = "m-1", + error = StoreResults.fetchError("offline"), + ) + + sink.onEvent(failure) + + assertEquals(listOf(failure), recorded) + assertEquals("offline", assertIs(failure.error).message) + } +} diff --git a/store6-graphql/README.md b/store6-graphql/README.md new file mode 100644 index 000000000..9d10b0068 --- /dev/null +++ b/store6-graphql/README.md @@ -0,0 +1,124 @@ +# store6-graphql + +GraphQL operation fetcher kit for Store v6. A store built with this module is a document +cache: each execution of one GraphQL operation, identified by operation name plus canonical +variables, caches one decoded response value. Everything here is `@ExperimentalStoreApi`. +The seam it consumes is a freeze candidate, not frozen — see [STABILITY.md](../STABILITY.md). + +The module has no dependency beyond `store6-core` and ships the same Kotlin Multiplatform +target set as `store6-core`. You bring the transport: a `GraphQlExecutor` you implement on +your HTTP client owns JSON encoding of variables, decoding of response data, and +translation of the response `errors` array into `GraphQlError` values. The kit never parses +GraphQL documents and never normalizes responses into entities. + +## Install + +Until the snapshot is published remotely, publish `store6-core` and `store6-graphql` to +Maven Local: + +```shell +./gradlew :store6-core:publishToMavenLocal :store6-graphql:publishToMavenLocal +``` + +```kotlin +repositories { + mavenLocal() + mavenCentral() +} + +dependencies { + implementation("org.mobilenativefoundation.store:store6-graphql:6.0.0-SNAPSHOT") +} +``` + +## First result + +```kotlin +val getUser = GraphQlOperation( + document = "query GetUser(\$id: ID!) { user(id: \$id) { id name } }", + name = "GetUser", +) + +val store = store { + graphQlFetcher(getUser) { request -> + // Your transport: encode request.variables, send request.operation.document, + // decode response data to User and response errors to GraphQlError values. + val response = api.execute(request.operation.document, request.variables) + GraphQlExecutorResult.Data(data = response.user, errors = response.errors) + } +} + +val ada = store.get(getUser.key(graphQlVariables { put("id", "1") })) +``` + +A second `get` of the same key, in any variable insertion order, serves the cached response +without executing. Every Store read policy applies unchanged: `Freshness.MustBeFresh` +re-executes, `Freshness.LocalOnly` never executes, and `store.invalidate(key)` marks one +variable set stale. + +## Entry points + +- `GraphQlOperation(document, name)` — an executable document plus the operation name. + The document is opaque to Store and reaches the executor unparsed. +- `GraphQlOperation.key(variables)` — the `GraphQlOperationKey` for one execution. +- `graphQlVariables { }` — builder for `GraphQlVariables` over the `GraphQlValue` model + (`NullValue`, `BooleanValue`, `IntValue`, `FloatValue`, `StringValue`, `ListValue`, + `ObjectValue`). +- `graphQlFetcher(operation, partialDataPolicy, executor)` — the seam `Fetcher`, also + installable through the `StoreBuilder.graphQlFetcher(...)` extension. +- `GraphQlExecutor` — `suspend execute(GraphQlRequest): GraphQlExecutorResult`. + Throw for transport failure. Return `Data(data, errors, etag)` for any response the + server produced, or `NotModified(etag)` to answer a conditional request. +- `GraphQlOperationException(operationName, errors)` — the `StoreError.Fetch` cause when a + response reports errors the fetcher does not adopt. + +## Cache identity + +`GraphQlOperationKey.canonicalId()` is `()`. The +variable rendering is JSON-shaped with no whitespace. Object keys sort in UTF-16 code-unit +order at every nesting depth, list order is significant, strings use JSON escaping, and an +explicit `null` variable is a different identity than an absent one. Two keys with equal +variables are one cache entry regardless of insertion order. + +`FloatValue` renders through the runtime's `Double.toString`, which differs across Kotlin +targets (JS drops the trailing `.0` of whole numbers). Prefer int or string variables when +canonical ids must match across runtimes, for example in a shared persistent source of +truth. + +The default namespace is `graphql:`, so +`store.invalidateNamespace(key.namespace)` marks every cached variable set of that one +operation stale. Pass a custom namespace to `GraphQlOperationKey` to partition differently. + +## Response mapping + +| Executor outcome | Store outcome | +| --- | --- | +| `Data(data != null, no errors)` | `FetcherResult.Success(data, etag)` | +| `Data(data != null, errors)` + `FailOnErrors` (default) | `FetcherResult.Error(GraphQlOperationException)` | +| `Data(data != null, errors)` + `AdoptPartialData` | `FetcherResult.Success(data, etag)` | +| `Data(data == null, errors)` | `FetcherResult.Error(GraphQlOperationException)` | +| `Data(data == null, no errors)` | `FetcherResult.Error` naming the protocol violation | +| `NotModified(etag)` | `FetcherResult.NotModified(etag)` — streams observe `StoreResult.Revalidated` | +| thrown exception | `FetcherResult.Error(cause)`; `CancellationException` propagates | +| key whose `operationName` is not the fetcher's operation | `FetcherResult.Error` naming both operations, without executing | + +`GraphQlPartialDataPolicy.FailOnErrors` is the default so a cached value never contains +error-substituted nulls. Opt into `AdoptPartialData` only when the decoded type tolerates +missing fields, because the store caches the partial value as if it were complete. + +## Conditional requests + +When the engine plans a conditional fetch for a key with a recorded ETag, +`GraphQlRequest.etag` is non-null. An executor that can revalidate (for example HTTP +`If-None-Match` on a GET-shaped operation) returns `NotModified` to confirm the cached +response. Executors without revalidation support ignore the ETag and execute normally. + +## Sample + +```shell +./gradlew :store6-graphql-sample:run +``` + +The headless JVM sample asserts four scenes over an in-process scripted executor: key +identity across variable orders, document-cache serve without re-execution, the +fail-vs-adopt partial-response split, and `NotModified` revalidation with recorded ETags. diff --git a/store6-graphql/api/android/store6-graphql.api b/store6-graphql/api/android/store6-graphql.api new file mode 100644 index 000000000..e6dac94f2 --- /dev/null +++ b/store6-graphql/api/android/store6-graphql.api @@ -0,0 +1,209 @@ +public final class org/mobilenativefoundation/store6/graphql/GraphQlError { + public fun (Ljava/lang/String;Ljava/util/List;)V + public synthetic fun (Ljava/lang/String;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun equals (Ljava/lang/Object;)Z + public final fun getMessage ()Ljava/lang/String; + public final fun getPath ()Ljava/util/List; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class org/mobilenativefoundation/store6/graphql/GraphQlError$PathSegment { +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlError$PathSegment$Field : org/mobilenativefoundation/store6/graphql/GraphQlError$PathSegment { + public fun (Ljava/lang/String;)V + public fun equals (Ljava/lang/Object;)Z + public final fun getName ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlError$PathSegment$Index : org/mobilenativefoundation/store6/graphql/GraphQlError$PathSegment { + public fun (I)V + public fun equals (Ljava/lang/Object;)Z + public final fun getIndex ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class org/mobilenativefoundation/store6/graphql/GraphQlExecutor { + public abstract fun execute (Lorg/mobilenativefoundation/store6/graphql/GraphQlRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class org/mobilenativefoundation/store6/graphql/GraphQlExecutorResult { +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlExecutorResult$Data : org/mobilenativefoundation/store6/graphql/GraphQlExecutorResult { + public fun (Ljava/lang/Object;Ljava/util/List;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/Object;Ljava/util/List;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getData ()Ljava/lang/Object; + public final fun getErrors ()Ljava/util/List; + public final fun getEtag ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlExecutorResult$NotModified : org/mobilenativefoundation/store6/graphql/GraphQlExecutorResult { + public fun ()V + public fun (Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getEtag ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlFetcherKt { + public static final fun graphQlFetcher (Lorg/mobilenativefoundation/store6/core/StoreBuilder;Lorg/mobilenativefoundation/store6/graphql/GraphQlOperation;Lorg/mobilenativefoundation/store6/graphql/GraphQlPartialDataPolicy;Lorg/mobilenativefoundation/store6/graphql/GraphQlExecutor;)V + public static final fun graphQlFetcher (Lorg/mobilenativefoundation/store6/graphql/GraphQlOperation;Lorg/mobilenativefoundation/store6/graphql/GraphQlPartialDataPolicy;Lorg/mobilenativefoundation/store6/graphql/GraphQlExecutor;)Lorg/mobilenativefoundation/store6/core/seam/Fetcher; + public static synthetic fun graphQlFetcher$default (Lorg/mobilenativefoundation/store6/core/StoreBuilder;Lorg/mobilenativefoundation/store6/graphql/GraphQlOperation;Lorg/mobilenativefoundation/store6/graphql/GraphQlPartialDataPolicy;Lorg/mobilenativefoundation/store6/graphql/GraphQlExecutor;ILjava/lang/Object;)V + public static synthetic fun graphQlFetcher$default (Lorg/mobilenativefoundation/store6/graphql/GraphQlOperation;Lorg/mobilenativefoundation/store6/graphql/GraphQlPartialDataPolicy;Lorg/mobilenativefoundation/store6/graphql/GraphQlExecutor;ILjava/lang/Object;)Lorg/mobilenativefoundation/store6/core/seam/Fetcher; +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlListBuilder { + public final fun add (D)V + public final fun add (I)V + public final fun add (J)V + public final fun add (Ljava/lang/String;)V + public final fun add (Lorg/mobilenativefoundation/store6/graphql/GraphQlValue;)V + public final fun add (Z)V + public final fun addList (Lkotlin/jvm/functions/Function1;)V + public final fun addNull ()V + public final fun addObject (Lkotlin/jvm/functions/Function1;)V +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlObjectBuilder { + public final fun put (Ljava/lang/String;D)V + public final fun put (Ljava/lang/String;I)V + public final fun put (Ljava/lang/String;J)V + public final fun put (Ljava/lang/String;Ljava/lang/String;)V + public final fun put (Ljava/lang/String;Lorg/mobilenativefoundation/store6/graphql/GraphQlValue;)V + public final fun put (Ljava/lang/String;Z)V + public final fun putList (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V + public final fun putNull (Ljava/lang/String;)V + public final fun putObject (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlOperation { + public fun (Ljava/lang/String;Ljava/lang/String;)V + public final fun getDocument ()Ljava/lang/String; + public final fun getName ()Ljava/lang/String; + public final fun key (Lorg/mobilenativefoundation/store6/graphql/GraphQlVariables;)Lorg/mobilenativefoundation/store6/graphql/GraphQlOperationKey; + public static synthetic fun key$default (Lorg/mobilenativefoundation/store6/graphql/GraphQlOperation;Lorg/mobilenativefoundation/store6/graphql/GraphQlVariables;ILjava/lang/Object;)Lorg/mobilenativefoundation/store6/graphql/GraphQlOperationKey; + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlOperationException : java/lang/Exception { + public fun (Ljava/lang/String;Ljava/util/List;)V + public final fun getErrors ()Ljava/util/List; + public final fun getOperationName ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlOperationKey : org/mobilenativefoundation/store6/core/StoreKey { + public fun (Ljava/lang/String;Lorg/mobilenativefoundation/store6/graphql/GraphQlVariables;Lorg/mobilenativefoundation/store6/core/StoreNamespace;)V + public synthetic fun (Ljava/lang/String;Lorg/mobilenativefoundation/store6/graphql/GraphQlVariables;Lorg/mobilenativefoundation/store6/core/StoreNamespace;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun canonicalId ()Ljava/lang/String; + public fun equals (Ljava/lang/Object;)Z + public fun getNamespace ()Lorg/mobilenativefoundation/store6/core/StoreNamespace; + public final fun getOperationName ()Ljava/lang/String; + public final fun getVariables ()Lorg/mobilenativefoundation/store6/graphql/GraphQlVariables; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class org/mobilenativefoundation/store6/graphql/GraphQlPartialDataPolicy { +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlPartialDataPolicy$AdoptPartialData : org/mobilenativefoundation/store6/graphql/GraphQlPartialDataPolicy { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/graphql/GraphQlPartialDataPolicy$AdoptPartialData; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlPartialDataPolicy$FailOnErrors : org/mobilenativefoundation/store6/graphql/GraphQlPartialDataPolicy { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/graphql/GraphQlPartialDataPolicy$FailOnErrors; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlRequest { + public fun (Lorg/mobilenativefoundation/store6/graphql/GraphQlOperation;Lorg/mobilenativefoundation/store6/graphql/GraphQlVariables;Ljava/lang/String;)V + public final fun getEtag ()Ljava/lang/String; + public final fun getOperation ()Lorg/mobilenativefoundation/store6/graphql/GraphQlOperation; + public final fun getVariables ()Lorg/mobilenativefoundation/store6/graphql/GraphQlVariables; + public fun toString ()Ljava/lang/String; +} + +public abstract interface class org/mobilenativefoundation/store6/graphql/GraphQlValue { +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlValue$BooleanValue : org/mobilenativefoundation/store6/graphql/GraphQlValue { + public fun (Z)V + public fun equals (Ljava/lang/Object;)Z + public final fun getValue ()Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlValue$FloatValue : org/mobilenativefoundation/store6/graphql/GraphQlValue { + public fun (D)V + public fun equals (Ljava/lang/Object;)Z + public final fun getValue ()D + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlValue$IntValue : org/mobilenativefoundation/store6/graphql/GraphQlValue { + public fun (J)V + public fun equals (Ljava/lang/Object;)Z + public final fun getValue ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlValue$ListValue : org/mobilenativefoundation/store6/graphql/GraphQlValue { + public fun (Ljava/util/List;)V + public fun equals (Ljava/lang/Object;)Z + public final fun getValues ()Ljava/util/List; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlValue$NullValue : org/mobilenativefoundation/store6/graphql/GraphQlValue { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/graphql/GraphQlValue$NullValue; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlValue$ObjectValue : org/mobilenativefoundation/store6/graphql/GraphQlValue { + public fun (Ljava/util/Map;)V + public fun equals (Ljava/lang/Object;)Z + public final fun getFields ()Ljava/util/Map; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlValue$StringValue : org/mobilenativefoundation/store6/graphql/GraphQlValue { + public fun (Ljava/lang/String;)V + public fun equals (Ljava/lang/Object;)Z + public final fun getValue ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlValueKt { + public static final fun graphQlVariables (Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store6/graphql/GraphQlVariables; +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlVariables { + public static final field Companion Lorg/mobilenativefoundation/store6/graphql/GraphQlVariables$Companion; + public fun (Ljava/util/Map;)V + public fun equals (Ljava/lang/Object;)Z + public final fun getEntries ()Ljava/util/Map; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlVariables$Companion { + public final fun getEmpty ()Lorg/mobilenativefoundation/store6/graphql/GraphQlVariables; +} + diff --git a/store6-graphql/api/jvm/store6-graphql.api b/store6-graphql/api/jvm/store6-graphql.api new file mode 100644 index 000000000..e6dac94f2 --- /dev/null +++ b/store6-graphql/api/jvm/store6-graphql.api @@ -0,0 +1,209 @@ +public final class org/mobilenativefoundation/store6/graphql/GraphQlError { + public fun (Ljava/lang/String;Ljava/util/List;)V + public synthetic fun (Ljava/lang/String;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun equals (Ljava/lang/Object;)Z + public final fun getMessage ()Ljava/lang/String; + public final fun getPath ()Ljava/util/List; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class org/mobilenativefoundation/store6/graphql/GraphQlError$PathSegment { +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlError$PathSegment$Field : org/mobilenativefoundation/store6/graphql/GraphQlError$PathSegment { + public fun (Ljava/lang/String;)V + public fun equals (Ljava/lang/Object;)Z + public final fun getName ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlError$PathSegment$Index : org/mobilenativefoundation/store6/graphql/GraphQlError$PathSegment { + public fun (I)V + public fun equals (Ljava/lang/Object;)Z + public final fun getIndex ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class org/mobilenativefoundation/store6/graphql/GraphQlExecutor { + public abstract fun execute (Lorg/mobilenativefoundation/store6/graphql/GraphQlRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class org/mobilenativefoundation/store6/graphql/GraphQlExecutorResult { +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlExecutorResult$Data : org/mobilenativefoundation/store6/graphql/GraphQlExecutorResult { + public fun (Ljava/lang/Object;Ljava/util/List;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/Object;Ljava/util/List;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getData ()Ljava/lang/Object; + public final fun getErrors ()Ljava/util/List; + public final fun getEtag ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlExecutorResult$NotModified : org/mobilenativefoundation/store6/graphql/GraphQlExecutorResult { + public fun ()V + public fun (Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getEtag ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlFetcherKt { + public static final fun graphQlFetcher (Lorg/mobilenativefoundation/store6/core/StoreBuilder;Lorg/mobilenativefoundation/store6/graphql/GraphQlOperation;Lorg/mobilenativefoundation/store6/graphql/GraphQlPartialDataPolicy;Lorg/mobilenativefoundation/store6/graphql/GraphQlExecutor;)V + public static final fun graphQlFetcher (Lorg/mobilenativefoundation/store6/graphql/GraphQlOperation;Lorg/mobilenativefoundation/store6/graphql/GraphQlPartialDataPolicy;Lorg/mobilenativefoundation/store6/graphql/GraphQlExecutor;)Lorg/mobilenativefoundation/store6/core/seam/Fetcher; + public static synthetic fun graphQlFetcher$default (Lorg/mobilenativefoundation/store6/core/StoreBuilder;Lorg/mobilenativefoundation/store6/graphql/GraphQlOperation;Lorg/mobilenativefoundation/store6/graphql/GraphQlPartialDataPolicy;Lorg/mobilenativefoundation/store6/graphql/GraphQlExecutor;ILjava/lang/Object;)V + public static synthetic fun graphQlFetcher$default (Lorg/mobilenativefoundation/store6/graphql/GraphQlOperation;Lorg/mobilenativefoundation/store6/graphql/GraphQlPartialDataPolicy;Lorg/mobilenativefoundation/store6/graphql/GraphQlExecutor;ILjava/lang/Object;)Lorg/mobilenativefoundation/store6/core/seam/Fetcher; +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlListBuilder { + public final fun add (D)V + public final fun add (I)V + public final fun add (J)V + public final fun add (Ljava/lang/String;)V + public final fun add (Lorg/mobilenativefoundation/store6/graphql/GraphQlValue;)V + public final fun add (Z)V + public final fun addList (Lkotlin/jvm/functions/Function1;)V + public final fun addNull ()V + public final fun addObject (Lkotlin/jvm/functions/Function1;)V +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlObjectBuilder { + public final fun put (Ljava/lang/String;D)V + public final fun put (Ljava/lang/String;I)V + public final fun put (Ljava/lang/String;J)V + public final fun put (Ljava/lang/String;Ljava/lang/String;)V + public final fun put (Ljava/lang/String;Lorg/mobilenativefoundation/store6/graphql/GraphQlValue;)V + public final fun put (Ljava/lang/String;Z)V + public final fun putList (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V + public final fun putNull (Ljava/lang/String;)V + public final fun putObject (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlOperation { + public fun (Ljava/lang/String;Ljava/lang/String;)V + public final fun getDocument ()Ljava/lang/String; + public final fun getName ()Ljava/lang/String; + public final fun key (Lorg/mobilenativefoundation/store6/graphql/GraphQlVariables;)Lorg/mobilenativefoundation/store6/graphql/GraphQlOperationKey; + public static synthetic fun key$default (Lorg/mobilenativefoundation/store6/graphql/GraphQlOperation;Lorg/mobilenativefoundation/store6/graphql/GraphQlVariables;ILjava/lang/Object;)Lorg/mobilenativefoundation/store6/graphql/GraphQlOperationKey; + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlOperationException : java/lang/Exception { + public fun (Ljava/lang/String;Ljava/util/List;)V + public final fun getErrors ()Ljava/util/List; + public final fun getOperationName ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlOperationKey : org/mobilenativefoundation/store6/core/StoreKey { + public fun (Ljava/lang/String;Lorg/mobilenativefoundation/store6/graphql/GraphQlVariables;Lorg/mobilenativefoundation/store6/core/StoreNamespace;)V + public synthetic fun (Ljava/lang/String;Lorg/mobilenativefoundation/store6/graphql/GraphQlVariables;Lorg/mobilenativefoundation/store6/core/StoreNamespace;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun canonicalId ()Ljava/lang/String; + public fun equals (Ljava/lang/Object;)Z + public fun getNamespace ()Lorg/mobilenativefoundation/store6/core/StoreNamespace; + public final fun getOperationName ()Ljava/lang/String; + public final fun getVariables ()Lorg/mobilenativefoundation/store6/graphql/GraphQlVariables; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class org/mobilenativefoundation/store6/graphql/GraphQlPartialDataPolicy { +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlPartialDataPolicy$AdoptPartialData : org/mobilenativefoundation/store6/graphql/GraphQlPartialDataPolicy { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/graphql/GraphQlPartialDataPolicy$AdoptPartialData; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlPartialDataPolicy$FailOnErrors : org/mobilenativefoundation/store6/graphql/GraphQlPartialDataPolicy { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/graphql/GraphQlPartialDataPolicy$FailOnErrors; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlRequest { + public fun (Lorg/mobilenativefoundation/store6/graphql/GraphQlOperation;Lorg/mobilenativefoundation/store6/graphql/GraphQlVariables;Ljava/lang/String;)V + public final fun getEtag ()Ljava/lang/String; + public final fun getOperation ()Lorg/mobilenativefoundation/store6/graphql/GraphQlOperation; + public final fun getVariables ()Lorg/mobilenativefoundation/store6/graphql/GraphQlVariables; + public fun toString ()Ljava/lang/String; +} + +public abstract interface class org/mobilenativefoundation/store6/graphql/GraphQlValue { +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlValue$BooleanValue : org/mobilenativefoundation/store6/graphql/GraphQlValue { + public fun (Z)V + public fun equals (Ljava/lang/Object;)Z + public final fun getValue ()Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlValue$FloatValue : org/mobilenativefoundation/store6/graphql/GraphQlValue { + public fun (D)V + public fun equals (Ljava/lang/Object;)Z + public final fun getValue ()D + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlValue$IntValue : org/mobilenativefoundation/store6/graphql/GraphQlValue { + public fun (J)V + public fun equals (Ljava/lang/Object;)Z + public final fun getValue ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlValue$ListValue : org/mobilenativefoundation/store6/graphql/GraphQlValue { + public fun (Ljava/util/List;)V + public fun equals (Ljava/lang/Object;)Z + public final fun getValues ()Ljava/util/List; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlValue$NullValue : org/mobilenativefoundation/store6/graphql/GraphQlValue { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/graphql/GraphQlValue$NullValue; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlValue$ObjectValue : org/mobilenativefoundation/store6/graphql/GraphQlValue { + public fun (Ljava/util/Map;)V + public fun equals (Ljava/lang/Object;)Z + public final fun getFields ()Ljava/util/Map; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlValue$StringValue : org/mobilenativefoundation/store6/graphql/GraphQlValue { + public fun (Ljava/lang/String;)V + public fun equals (Ljava/lang/Object;)Z + public final fun getValue ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlValueKt { + public static final fun graphQlVariables (Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store6/graphql/GraphQlVariables; +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlVariables { + public static final field Companion Lorg/mobilenativefoundation/store6/graphql/GraphQlVariables$Companion; + public fun (Ljava/util/Map;)V + public fun equals (Ljava/lang/Object;)Z + public final fun getEntries ()Ljava/util/Map; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/graphql/GraphQlVariables$Companion { + public final fun getEmpty ()Lorg/mobilenativefoundation/store6/graphql/GraphQlVariables; +} + diff --git a/store6-graphql/api/store6-graphql.klib.api b/store6-graphql/api/store6-graphql.klib.api new file mode 100644 index 000000000..c34aa307d --- /dev/null +++ b/store6-graphql/api/store6-graphql.klib.api @@ -0,0 +1,250 @@ +// Klib ABI Dump +// Targets: [iosArm64, iosSimulatorArm64, iosX64, js, linuxX64, macosArm64, mingwX64, tvosArm64, wasmJs, watchosArm64] +// Rendering settings: +// - Signature version: 2 +// - Show manifest properties: true +// - Show declarations: true + +// Library unique name: +abstract fun interface <#A: kotlin/Any> org.mobilenativefoundation.store6.graphql/GraphQlExecutor { // org.mobilenativefoundation.store6.graphql/GraphQlExecutor|null[0] + abstract suspend fun execute(org.mobilenativefoundation.store6.graphql/GraphQlRequest): org.mobilenativefoundation.store6.graphql/GraphQlExecutorResult<#A> // org.mobilenativefoundation.store6.graphql/GraphQlExecutor.execute|execute(org.mobilenativefoundation.store6.graphql.GraphQlRequest){}[0] +} + +sealed interface <#A: out kotlin/Any> org.mobilenativefoundation.store6.graphql/GraphQlExecutorResult { // org.mobilenativefoundation.store6.graphql/GraphQlExecutorResult|null[0] + final class <#A1: kotlin/Any> Data : org.mobilenativefoundation.store6.graphql/GraphQlExecutorResult<#A1> { // org.mobilenativefoundation.store6.graphql/GraphQlExecutorResult.Data|null[0] + constructor (#A1?, kotlin.collections/List = ..., kotlin/String? = ...) // org.mobilenativefoundation.store6.graphql/GraphQlExecutorResult.Data.|(1:0?;kotlin.collections.List;kotlin.String?){}[0] + + final val data // org.mobilenativefoundation.store6.graphql/GraphQlExecutorResult.Data.data|{}data[0] + final fun (): #A1? // org.mobilenativefoundation.store6.graphql/GraphQlExecutorResult.Data.data.|(){}[0] + final val errors // org.mobilenativefoundation.store6.graphql/GraphQlExecutorResult.Data.errors|{}errors[0] + final fun (): kotlin.collections/List // org.mobilenativefoundation.store6.graphql/GraphQlExecutorResult.Data.errors.|(){}[0] + final val etag // org.mobilenativefoundation.store6.graphql/GraphQlExecutorResult.Data.etag|{}etag[0] + final fun (): kotlin/String? // org.mobilenativefoundation.store6.graphql/GraphQlExecutorResult.Data.etag.|(){}[0] + } + + final class NotModified : org.mobilenativefoundation.store6.graphql/GraphQlExecutorResult { // org.mobilenativefoundation.store6.graphql/GraphQlExecutorResult.NotModified|null[0] + constructor (kotlin/String? = ...) // org.mobilenativefoundation.store6.graphql/GraphQlExecutorResult.NotModified.|(kotlin.String?){}[0] + + final val etag // org.mobilenativefoundation.store6.graphql/GraphQlExecutorResult.NotModified.etag|{}etag[0] + final fun (): kotlin/String? // org.mobilenativefoundation.store6.graphql/GraphQlExecutorResult.NotModified.etag.|(){}[0] + } +} + +sealed interface org.mobilenativefoundation.store6.graphql/GraphQlPartialDataPolicy { // org.mobilenativefoundation.store6.graphql/GraphQlPartialDataPolicy|null[0] + final object AdoptPartialData : org.mobilenativefoundation.store6.graphql/GraphQlPartialDataPolicy { // org.mobilenativefoundation.store6.graphql/GraphQlPartialDataPolicy.AdoptPartialData|null[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.mobilenativefoundation.store6.graphql/GraphQlPartialDataPolicy.AdoptPartialData.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.mobilenativefoundation.store6.graphql/GraphQlPartialDataPolicy.AdoptPartialData.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.mobilenativefoundation.store6.graphql/GraphQlPartialDataPolicy.AdoptPartialData.toString|toString(){}[0] + } + + final object FailOnErrors : org.mobilenativefoundation.store6.graphql/GraphQlPartialDataPolicy { // org.mobilenativefoundation.store6.graphql/GraphQlPartialDataPolicy.FailOnErrors|null[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.mobilenativefoundation.store6.graphql/GraphQlPartialDataPolicy.FailOnErrors.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.mobilenativefoundation.store6.graphql/GraphQlPartialDataPolicy.FailOnErrors.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.mobilenativefoundation.store6.graphql/GraphQlPartialDataPolicy.FailOnErrors.toString|toString(){}[0] + } +} + +sealed interface org.mobilenativefoundation.store6.graphql/GraphQlValue { // org.mobilenativefoundation.store6.graphql/GraphQlValue|null[0] + final class BooleanValue : org.mobilenativefoundation.store6.graphql/GraphQlValue { // org.mobilenativefoundation.store6.graphql/GraphQlValue.BooleanValue|null[0] + constructor (kotlin/Boolean) // org.mobilenativefoundation.store6.graphql/GraphQlValue.BooleanValue.|(kotlin.Boolean){}[0] + + final val value // org.mobilenativefoundation.store6.graphql/GraphQlValue.BooleanValue.value|{}value[0] + final fun (): kotlin/Boolean // org.mobilenativefoundation.store6.graphql/GraphQlValue.BooleanValue.value.|(){}[0] + + final fun equals(kotlin/Any?): kotlin/Boolean // org.mobilenativefoundation.store6.graphql/GraphQlValue.BooleanValue.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.mobilenativefoundation.store6.graphql/GraphQlValue.BooleanValue.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.mobilenativefoundation.store6.graphql/GraphQlValue.BooleanValue.toString|toString(){}[0] + } + + final class FloatValue : org.mobilenativefoundation.store6.graphql/GraphQlValue { // org.mobilenativefoundation.store6.graphql/GraphQlValue.FloatValue|null[0] + constructor (kotlin/Double) // org.mobilenativefoundation.store6.graphql/GraphQlValue.FloatValue.|(kotlin.Double){}[0] + + final val value // org.mobilenativefoundation.store6.graphql/GraphQlValue.FloatValue.value|{}value[0] + final fun (): kotlin/Double // org.mobilenativefoundation.store6.graphql/GraphQlValue.FloatValue.value.|(){}[0] + + final fun equals(kotlin/Any?): kotlin/Boolean // org.mobilenativefoundation.store6.graphql/GraphQlValue.FloatValue.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.mobilenativefoundation.store6.graphql/GraphQlValue.FloatValue.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.mobilenativefoundation.store6.graphql/GraphQlValue.FloatValue.toString|toString(){}[0] + } + + final class IntValue : org.mobilenativefoundation.store6.graphql/GraphQlValue { // org.mobilenativefoundation.store6.graphql/GraphQlValue.IntValue|null[0] + constructor (kotlin/Long) // org.mobilenativefoundation.store6.graphql/GraphQlValue.IntValue.|(kotlin.Long){}[0] + + final val value // org.mobilenativefoundation.store6.graphql/GraphQlValue.IntValue.value|{}value[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.graphql/GraphQlValue.IntValue.value.|(){}[0] + + final fun equals(kotlin/Any?): kotlin/Boolean // org.mobilenativefoundation.store6.graphql/GraphQlValue.IntValue.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.mobilenativefoundation.store6.graphql/GraphQlValue.IntValue.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.mobilenativefoundation.store6.graphql/GraphQlValue.IntValue.toString|toString(){}[0] + } + + final class ListValue : org.mobilenativefoundation.store6.graphql/GraphQlValue { // org.mobilenativefoundation.store6.graphql/GraphQlValue.ListValue|null[0] + constructor (kotlin.collections/List) // org.mobilenativefoundation.store6.graphql/GraphQlValue.ListValue.|(kotlin.collections.List){}[0] + + final val values // org.mobilenativefoundation.store6.graphql/GraphQlValue.ListValue.values|{}values[0] + final fun (): kotlin.collections/List // org.mobilenativefoundation.store6.graphql/GraphQlValue.ListValue.values.|(){}[0] + + final fun equals(kotlin/Any?): kotlin/Boolean // org.mobilenativefoundation.store6.graphql/GraphQlValue.ListValue.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.mobilenativefoundation.store6.graphql/GraphQlValue.ListValue.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.mobilenativefoundation.store6.graphql/GraphQlValue.ListValue.toString|toString(){}[0] + } + + final class ObjectValue : org.mobilenativefoundation.store6.graphql/GraphQlValue { // org.mobilenativefoundation.store6.graphql/GraphQlValue.ObjectValue|null[0] + constructor (kotlin.collections/Map) // org.mobilenativefoundation.store6.graphql/GraphQlValue.ObjectValue.|(kotlin.collections.Map){}[0] + + final val fields // org.mobilenativefoundation.store6.graphql/GraphQlValue.ObjectValue.fields|{}fields[0] + final fun (): kotlin.collections/Map // org.mobilenativefoundation.store6.graphql/GraphQlValue.ObjectValue.fields.|(){}[0] + + final fun equals(kotlin/Any?): kotlin/Boolean // org.mobilenativefoundation.store6.graphql/GraphQlValue.ObjectValue.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.mobilenativefoundation.store6.graphql/GraphQlValue.ObjectValue.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.mobilenativefoundation.store6.graphql/GraphQlValue.ObjectValue.toString|toString(){}[0] + } + + final class StringValue : org.mobilenativefoundation.store6.graphql/GraphQlValue { // org.mobilenativefoundation.store6.graphql/GraphQlValue.StringValue|null[0] + constructor (kotlin/String) // org.mobilenativefoundation.store6.graphql/GraphQlValue.StringValue.|(kotlin.String){}[0] + + final val value // org.mobilenativefoundation.store6.graphql/GraphQlValue.StringValue.value|{}value[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.graphql/GraphQlValue.StringValue.value.|(){}[0] + + final fun equals(kotlin/Any?): kotlin/Boolean // org.mobilenativefoundation.store6.graphql/GraphQlValue.StringValue.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.mobilenativefoundation.store6.graphql/GraphQlValue.StringValue.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.mobilenativefoundation.store6.graphql/GraphQlValue.StringValue.toString|toString(){}[0] + } + + final object NullValue : org.mobilenativefoundation.store6.graphql/GraphQlValue { // org.mobilenativefoundation.store6.graphql/GraphQlValue.NullValue|null[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.mobilenativefoundation.store6.graphql/GraphQlValue.NullValue.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.mobilenativefoundation.store6.graphql/GraphQlValue.NullValue.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.mobilenativefoundation.store6.graphql/GraphQlValue.NullValue.toString|toString(){}[0] + } +} + +final class org.mobilenativefoundation.store6.graphql/GraphQlError { // org.mobilenativefoundation.store6.graphql/GraphQlError|null[0] + constructor (kotlin/String, kotlin.collections/List = ...) // org.mobilenativefoundation.store6.graphql/GraphQlError.|(kotlin.String;kotlin.collections.List){}[0] + + final val message // org.mobilenativefoundation.store6.graphql/GraphQlError.message|{}message[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.graphql/GraphQlError.message.|(){}[0] + final val path // org.mobilenativefoundation.store6.graphql/GraphQlError.path|{}path[0] + final fun (): kotlin.collections/List // org.mobilenativefoundation.store6.graphql/GraphQlError.path.|(){}[0] + + final fun equals(kotlin/Any?): kotlin/Boolean // org.mobilenativefoundation.store6.graphql/GraphQlError.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.mobilenativefoundation.store6.graphql/GraphQlError.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.mobilenativefoundation.store6.graphql/GraphQlError.toString|toString(){}[0] + + sealed interface PathSegment { // org.mobilenativefoundation.store6.graphql/GraphQlError.PathSegment|null[0] + final class Field : org.mobilenativefoundation.store6.graphql/GraphQlError.PathSegment { // org.mobilenativefoundation.store6.graphql/GraphQlError.PathSegment.Field|null[0] + constructor (kotlin/String) // org.mobilenativefoundation.store6.graphql/GraphQlError.PathSegment.Field.|(kotlin.String){}[0] + + final val name // org.mobilenativefoundation.store6.graphql/GraphQlError.PathSegment.Field.name|{}name[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.graphql/GraphQlError.PathSegment.Field.name.|(){}[0] + + final fun equals(kotlin/Any?): kotlin/Boolean // org.mobilenativefoundation.store6.graphql/GraphQlError.PathSegment.Field.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.mobilenativefoundation.store6.graphql/GraphQlError.PathSegment.Field.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.mobilenativefoundation.store6.graphql/GraphQlError.PathSegment.Field.toString|toString(){}[0] + } + + final class Index : org.mobilenativefoundation.store6.graphql/GraphQlError.PathSegment { // org.mobilenativefoundation.store6.graphql/GraphQlError.PathSegment.Index|null[0] + constructor (kotlin/Int) // org.mobilenativefoundation.store6.graphql/GraphQlError.PathSegment.Index.|(kotlin.Int){}[0] + + final val index // org.mobilenativefoundation.store6.graphql/GraphQlError.PathSegment.Index.index|{}index[0] + final fun (): kotlin/Int // org.mobilenativefoundation.store6.graphql/GraphQlError.PathSegment.Index.index.|(){}[0] + + final fun equals(kotlin/Any?): kotlin/Boolean // org.mobilenativefoundation.store6.graphql/GraphQlError.PathSegment.Index.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.mobilenativefoundation.store6.graphql/GraphQlError.PathSegment.Index.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.mobilenativefoundation.store6.graphql/GraphQlError.PathSegment.Index.toString|toString(){}[0] + } + } +} + +final class org.mobilenativefoundation.store6.graphql/GraphQlListBuilder { // org.mobilenativefoundation.store6.graphql/GraphQlListBuilder|null[0] + final fun add(kotlin/Boolean) // org.mobilenativefoundation.store6.graphql/GraphQlListBuilder.add|add(kotlin.Boolean){}[0] + final fun add(kotlin/Double) // org.mobilenativefoundation.store6.graphql/GraphQlListBuilder.add|add(kotlin.Double){}[0] + final fun add(kotlin/Int) // org.mobilenativefoundation.store6.graphql/GraphQlListBuilder.add|add(kotlin.Int){}[0] + final fun add(kotlin/Long) // org.mobilenativefoundation.store6.graphql/GraphQlListBuilder.add|add(kotlin.Long){}[0] + final fun add(kotlin/String) // org.mobilenativefoundation.store6.graphql/GraphQlListBuilder.add|add(kotlin.String){}[0] + final fun add(org.mobilenativefoundation.store6.graphql/GraphQlValue) // org.mobilenativefoundation.store6.graphql/GraphQlListBuilder.add|add(org.mobilenativefoundation.store6.graphql.GraphQlValue){}[0] + final fun addList(kotlin/Function1) // org.mobilenativefoundation.store6.graphql/GraphQlListBuilder.addList|addList(kotlin.Function1){}[0] + final fun addNull() // org.mobilenativefoundation.store6.graphql/GraphQlListBuilder.addNull|addNull(){}[0] + final fun addObject(kotlin/Function1) // org.mobilenativefoundation.store6.graphql/GraphQlListBuilder.addObject|addObject(kotlin.Function1){}[0] +} + +final class org.mobilenativefoundation.store6.graphql/GraphQlObjectBuilder { // org.mobilenativefoundation.store6.graphql/GraphQlObjectBuilder|null[0] + final fun put(kotlin/String, kotlin/Boolean) // org.mobilenativefoundation.store6.graphql/GraphQlObjectBuilder.put|put(kotlin.String;kotlin.Boolean){}[0] + final fun put(kotlin/String, kotlin/Double) // org.mobilenativefoundation.store6.graphql/GraphQlObjectBuilder.put|put(kotlin.String;kotlin.Double){}[0] + final fun put(kotlin/String, kotlin/Int) // org.mobilenativefoundation.store6.graphql/GraphQlObjectBuilder.put|put(kotlin.String;kotlin.Int){}[0] + final fun put(kotlin/String, kotlin/Long) // org.mobilenativefoundation.store6.graphql/GraphQlObjectBuilder.put|put(kotlin.String;kotlin.Long){}[0] + final fun put(kotlin/String, kotlin/String) // org.mobilenativefoundation.store6.graphql/GraphQlObjectBuilder.put|put(kotlin.String;kotlin.String){}[0] + final fun put(kotlin/String, org.mobilenativefoundation.store6.graphql/GraphQlValue) // org.mobilenativefoundation.store6.graphql/GraphQlObjectBuilder.put|put(kotlin.String;org.mobilenativefoundation.store6.graphql.GraphQlValue){}[0] + final fun putList(kotlin/String, kotlin/Function1) // org.mobilenativefoundation.store6.graphql/GraphQlObjectBuilder.putList|putList(kotlin.String;kotlin.Function1){}[0] + final fun putNull(kotlin/String) // org.mobilenativefoundation.store6.graphql/GraphQlObjectBuilder.putNull|putNull(kotlin.String){}[0] + final fun putObject(kotlin/String, kotlin/Function1) // org.mobilenativefoundation.store6.graphql/GraphQlObjectBuilder.putObject|putObject(kotlin.String;kotlin.Function1){}[0] +} + +final class org.mobilenativefoundation.store6.graphql/GraphQlOperation { // org.mobilenativefoundation.store6.graphql/GraphQlOperation|null[0] + constructor (kotlin/String, kotlin/String) // org.mobilenativefoundation.store6.graphql/GraphQlOperation.|(kotlin.String;kotlin.String){}[0] + + final val document // org.mobilenativefoundation.store6.graphql/GraphQlOperation.document|{}document[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.graphql/GraphQlOperation.document.|(){}[0] + final val name // org.mobilenativefoundation.store6.graphql/GraphQlOperation.name|{}name[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.graphql/GraphQlOperation.name.|(){}[0] + + final fun key(org.mobilenativefoundation.store6.graphql/GraphQlVariables = ...): org.mobilenativefoundation.store6.graphql/GraphQlOperationKey // org.mobilenativefoundation.store6.graphql/GraphQlOperation.key|key(org.mobilenativefoundation.store6.graphql.GraphQlVariables){}[0] + final fun toString(): kotlin/String // org.mobilenativefoundation.store6.graphql/GraphQlOperation.toString|toString(){}[0] +} + +final class org.mobilenativefoundation.store6.graphql/GraphQlOperationException : kotlin/Exception { // org.mobilenativefoundation.store6.graphql/GraphQlOperationException|null[0] + constructor (kotlin/String, kotlin.collections/List) // org.mobilenativefoundation.store6.graphql/GraphQlOperationException.|(kotlin.String;kotlin.collections.List){}[0] + + final val errors // org.mobilenativefoundation.store6.graphql/GraphQlOperationException.errors|{}errors[0] + final fun (): kotlin.collections/List // org.mobilenativefoundation.store6.graphql/GraphQlOperationException.errors.|(){}[0] + final val operationName // org.mobilenativefoundation.store6.graphql/GraphQlOperationException.operationName|{}operationName[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.graphql/GraphQlOperationException.operationName.|(){}[0] +} + +final class org.mobilenativefoundation.store6.graphql/GraphQlOperationKey : org.mobilenativefoundation.store6.core/StoreKey { // org.mobilenativefoundation.store6.graphql/GraphQlOperationKey|null[0] + constructor (kotlin/String, org.mobilenativefoundation.store6.graphql/GraphQlVariables = ..., org.mobilenativefoundation.store6.core/StoreNamespace = ...) // org.mobilenativefoundation.store6.graphql/GraphQlOperationKey.|(kotlin.String;org.mobilenativefoundation.store6.graphql.GraphQlVariables;org.mobilenativefoundation.store6.core.StoreNamespace){}[0] + + final val namespace // org.mobilenativefoundation.store6.graphql/GraphQlOperationKey.namespace|{}namespace[0] + final fun (): org.mobilenativefoundation.store6.core/StoreNamespace // org.mobilenativefoundation.store6.graphql/GraphQlOperationKey.namespace.|(){}[0] + final val operationName // org.mobilenativefoundation.store6.graphql/GraphQlOperationKey.operationName|{}operationName[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.graphql/GraphQlOperationKey.operationName.|(){}[0] + final val variables // org.mobilenativefoundation.store6.graphql/GraphQlOperationKey.variables|{}variables[0] + final fun (): org.mobilenativefoundation.store6.graphql/GraphQlVariables // org.mobilenativefoundation.store6.graphql/GraphQlOperationKey.variables.|(){}[0] + + final fun canonicalId(): kotlin/String // org.mobilenativefoundation.store6.graphql/GraphQlOperationKey.canonicalId|canonicalId(){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.mobilenativefoundation.store6.graphql/GraphQlOperationKey.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.mobilenativefoundation.store6.graphql/GraphQlOperationKey.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.mobilenativefoundation.store6.graphql/GraphQlOperationKey.toString|toString(){}[0] +} + +final class org.mobilenativefoundation.store6.graphql/GraphQlRequest { // org.mobilenativefoundation.store6.graphql/GraphQlRequest|null[0] + constructor (org.mobilenativefoundation.store6.graphql/GraphQlOperation, org.mobilenativefoundation.store6.graphql/GraphQlVariables, kotlin/String?) // org.mobilenativefoundation.store6.graphql/GraphQlRequest.|(org.mobilenativefoundation.store6.graphql.GraphQlOperation;org.mobilenativefoundation.store6.graphql.GraphQlVariables;kotlin.String?){}[0] + + final val etag // org.mobilenativefoundation.store6.graphql/GraphQlRequest.etag|{}etag[0] + final fun (): kotlin/String? // org.mobilenativefoundation.store6.graphql/GraphQlRequest.etag.|(){}[0] + final val operation // org.mobilenativefoundation.store6.graphql/GraphQlRequest.operation|{}operation[0] + final fun (): org.mobilenativefoundation.store6.graphql/GraphQlOperation // org.mobilenativefoundation.store6.graphql/GraphQlRequest.operation.|(){}[0] + final val variables // org.mobilenativefoundation.store6.graphql/GraphQlRequest.variables|{}variables[0] + final fun (): org.mobilenativefoundation.store6.graphql/GraphQlVariables // org.mobilenativefoundation.store6.graphql/GraphQlRequest.variables.|(){}[0] + + final fun toString(): kotlin/String // org.mobilenativefoundation.store6.graphql/GraphQlRequest.toString|toString(){}[0] +} + +final class org.mobilenativefoundation.store6.graphql/GraphQlVariables { // org.mobilenativefoundation.store6.graphql/GraphQlVariables|null[0] + constructor (kotlin.collections/Map) // org.mobilenativefoundation.store6.graphql/GraphQlVariables.|(kotlin.collections.Map){}[0] + + final val entries // org.mobilenativefoundation.store6.graphql/GraphQlVariables.entries|{}entries[0] + final fun (): kotlin.collections/Map // org.mobilenativefoundation.store6.graphql/GraphQlVariables.entries.|(){}[0] + + final fun equals(kotlin/Any?): kotlin/Boolean // org.mobilenativefoundation.store6.graphql/GraphQlVariables.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.mobilenativefoundation.store6.graphql/GraphQlVariables.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.mobilenativefoundation.store6.graphql/GraphQlVariables.toString|toString(){}[0] + + final object Companion { // org.mobilenativefoundation.store6.graphql/GraphQlVariables.Companion|null[0] + final val Empty // org.mobilenativefoundation.store6.graphql/GraphQlVariables.Companion.Empty|{}Empty[0] + final fun (): org.mobilenativefoundation.store6.graphql/GraphQlVariables // org.mobilenativefoundation.store6.graphql/GraphQlVariables.Companion.Empty.|(){}[0] + } +} + +final fun <#A: kotlin/Any> (org.mobilenativefoundation.store6.core/StoreBuilder).org.mobilenativefoundation.store6.graphql/graphQlFetcher(org.mobilenativefoundation.store6.graphql/GraphQlOperation, org.mobilenativefoundation.store6.graphql/GraphQlPartialDataPolicy = ..., org.mobilenativefoundation.store6.graphql/GraphQlExecutor<#A>) // org.mobilenativefoundation.store6.graphql/graphQlFetcher|graphQlFetcher@org.mobilenativefoundation.store6.core.StoreBuilder(org.mobilenativefoundation.store6.graphql.GraphQlOperation;org.mobilenativefoundation.store6.graphql.GraphQlPartialDataPolicy;org.mobilenativefoundation.store6.graphql.GraphQlExecutor<0:0>){0§}[0] +final fun <#A: kotlin/Any> org.mobilenativefoundation.store6.graphql/graphQlFetcher(org.mobilenativefoundation.store6.graphql/GraphQlOperation, org.mobilenativefoundation.store6.graphql/GraphQlPartialDataPolicy = ..., org.mobilenativefoundation.store6.graphql/GraphQlExecutor<#A>): org.mobilenativefoundation.store6.core.seam/Fetcher // org.mobilenativefoundation.store6.graphql/graphQlFetcher|graphQlFetcher(org.mobilenativefoundation.store6.graphql.GraphQlOperation;org.mobilenativefoundation.store6.graphql.GraphQlPartialDataPolicy;org.mobilenativefoundation.store6.graphql.GraphQlExecutor<0:0>){0§}[0] +final fun org.mobilenativefoundation.store6.graphql/graphQlVariables(kotlin/Function1): org.mobilenativefoundation.store6.graphql/GraphQlVariables // org.mobilenativefoundation.store6.graphql/graphQlVariables|graphQlVariables(kotlin.Function1){}[0] diff --git a/store6-graphql/build.gradle.kts b/store6-graphql/build.gradle.kts new file mode 100644 index 000000000..a4d21b353 --- /dev/null +++ b/store6-graphql/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + id("org.mobilenativefoundation.store.store6.multiplatform") +} + +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + api(projects.store6Core) + } + } + + val commonTest by getting { + dependencies { + implementation(projects.store6Testing) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.turbine) + } + } + } +} + +android { + namespace = "org.mobilenativefoundation.store6.graphql" +} diff --git a/store6-graphql/gradle.properties b/store6-graphql/gradle.properties new file mode 100644 index 000000000..0e312013f --- /dev/null +++ b/store6-graphql/gradle.properties @@ -0,0 +1,3 @@ +VERSION_NAME=6.0.0-SNAPSHOT +POM_NAME=store6-graphql +POM_ARTIFACT_ID=store6-graphql diff --git a/store6-graphql/sample/build.gradle.kts b/store6-graphql/sample/build.gradle.kts new file mode 100644 index 000000000..b0820b91a --- /dev/null +++ b/store6-graphql/sample/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + id("org.jetbrains.kotlin.jvm") + application +} + +kotlin { jvmToolchain(11) } + +dependencies { + implementation(projects.store6Graphql) +} + +application { mainClass.set("org.mobilenativefoundation.store6.graphql.sample.MainKt") } diff --git a/store6-graphql/sample/src/main/kotlin/org/mobilenativefoundation/store6/graphql/sample/Main.kt b/store6-graphql/sample/src/main/kotlin/org/mobilenativefoundation/store6/graphql/sample/Main.kt new file mode 100644 index 000000000..0cb9b1f85 --- /dev/null +++ b/store6-graphql/sample/src/main/kotlin/org/mobilenativefoundation/store6/graphql/sample/Main.kt @@ -0,0 +1,176 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.graphql.sample + +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreException +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.store +import org.mobilenativefoundation.store6.graphql.GraphQlError +import org.mobilenativefoundation.store6.graphql.GraphQlExecutor +import org.mobilenativefoundation.store6.graphql.GraphQlExecutorResult +import org.mobilenativefoundation.store6.graphql.GraphQlOperation +import org.mobilenativefoundation.store6.graphql.GraphQlOperationException +import org.mobilenativefoundation.store6.graphql.GraphQlOperationKey +import org.mobilenativefoundation.store6.graphql.GraphQlPartialDataPolicy +import org.mobilenativefoundation.store6.graphql.GraphQlRequest +import org.mobilenativefoundation.store6.graphql.GraphQlVariables +import org.mobilenativefoundation.store6.graphql.graphQlFetcher +import org.mobilenativefoundation.store6.graphql.graphQlVariables + +public fun main(): Unit = + runBlocking { + withTimeout(SAMPLE_TIMEOUT_MILLIS) { + runSample() + } + } + +private suspend fun runSample() { + val operation = + GraphQlOperation( + document = "query GetUser(\$id: ID!, \$locale: String) { user(id: \$id) { id name } }", + name = "GetUser", + ) + val ada = User(id = "1", name = "Ada") + + // Scene 1: variable insertion order does not split cache identity. + val byIdThenLocale = + graphQlVariables { + put("id", "1") + put("locale", "en") + } + val byLocaleThenId = + graphQlVariables { + put("locale", "en") + put("id", "1") + } + val canonicalId = operation.key(byIdThenLocale).canonicalId() + check(operation.key(byIdThenLocale) == operation.key(byLocaleThenId)) + check(canonicalId == "GetUser({\"id\":\"1\",\"locale\":\"en\"})") + println("Scene 1: both variable orders share one identity; canonicalId=$canonicalId") + + // Scene 2: the store is a document cache; a resident response is served without re-execution. + val cacheExecutor = ScriptedExecutor() + cacheExecutor.enqueue(byIdThenLocale, GraphQlExecutorResult.Data(data = ada)) + val cacheStore = userStore(operation, cacheExecutor) + try { + check(cacheStore.get(operation.key(byIdThenLocale)) == ada) + check(cacheStore.get(operation.key(byLocaleThenId)) == ada) + check(cacheExecutor.callCount(byIdThenLocale) == 1) + println("Scene 2: two reads across both variable orders; executions=1") + } finally { + cacheStore.close() + } + + // Scene 3: a partial response fails by default and is adopted only on opt-in. + val partialVariables = graphQlVariables { put("id", "2") } + val partialResponse = { + GraphQlExecutorResult.Data( + data = User(id = "2", name = "Grace"), + errors = listOf(GraphQlError(message = "avatar resolution failed")), + ) + } + val failExecutor = ScriptedExecutor() + failExecutor.enqueue(partialVariables, partialResponse()) + val failStore = userStore(operation, failExecutor) + val adoptExecutor = ScriptedExecutor() + adoptExecutor.enqueue(partialVariables, partialResponse()) + val adoptStore = userStore(operation, adoptExecutor, GraphQlPartialDataPolicy.AdoptPartialData) + try { + val failure = + runCatching { + failStore.get(operation.key(partialVariables), Freshness.MustBeFresh) + }.exceptionOrNull() + val storeFailure = checkNotNull(failure as? StoreException) { "expected StoreException, was $failure" } + val fetchError = checkNotNull(storeFailure.error as? StoreError.Fetch) { "expected StoreError.Fetch" } + val operationException = + checkNotNull(fetchError.cause as? GraphQlOperationException) { + "expected GraphQlOperationException, was ${fetchError.cause}" + } + check(operationException.operationName == "GetUser") + + val adopted = adoptStore.get(operation.key(partialVariables)) + check(adopted == User(id = "2", name = "Grace")) + println( + "Scene 3: FailOnErrors surfaced '${operationException.errors.single().message}'; " + + "AdoptPartialData cached $adopted", + ) + } finally { + failStore.close() + adoptStore.close() + } + + // Scene 4: a conditional refetch answered NotModified revalidates the cached response. + val revalidationVariables = graphQlVariables { put("id", "1") } + val revalidationExecutor = ScriptedExecutor() + revalidationExecutor.enqueue( + revalidationVariables, + GraphQlExecutorResult.Data(data = ada, etag = "e1"), + // A cold-baseline 304 may legally self-heal with one replanned conditional fetch. + GraphQlExecutorResult.NotModified("e1"), + GraphQlExecutorResult.NotModified("e1"), + ) + val revalidationStore = userStore(operation, revalidationExecutor) + try { + val key = operation.key(revalidationVariables) + check(revalidationStore.get(key) == ada) + revalidationStore.invalidate(key) + revalidationStore.stream(key).first { result -> result is StoreResult.Revalidated } + check(revalidationStore.get(key) == ada) + val etags = revalidationExecutor.etags(revalidationVariables) + check(etags.size in 2..3) { "expected 2..3 executions, recorded $etags" } + check(etags[0] == null) + check(etags.drop(1).all { etag -> etag == "e1" }) + println("Scene 4: NotModified revalidated the cached response; recorded etags=$etags") + } finally { + revalidationStore.close() + } +} + +private data class User( + val id: String, + val name: String, +) + +private fun userStore( + operation: GraphQlOperation, + executor: GraphQlExecutor, + partialDataPolicy: GraphQlPartialDataPolicy = GraphQlPartialDataPolicy.FailOnErrors, +): Store = + store { + graphQlFetcher(operation, partialDataPolicy, executor) + } + +private class ScriptedExecutor : GraphQlExecutor { + private val scripts = ConcurrentHashMap>>() + private val recordedEtags = ConcurrentHashMap>() + + fun enqueue( + variables: GraphQlVariables, + vararg results: GraphQlExecutorResult, + ) { + scripts.computeIfAbsent(variables) { ArrayDeque() }.addAll(results) + } + + fun callCount(variables: GraphQlVariables): Int = etags(variables).size + + fun etags(variables: GraphQlVariables): List = + recordedEtags[variables].orEmpty().toList() + + override suspend fun execute(request: GraphQlRequest): GraphQlExecutorResult { + recordedEtags.computeIfAbsent(request.variables) { + java.util.Collections.synchronizedList(mutableListOf()) + }.add(request.etag) + return checkNotNull(scripts[request.variables]?.removeFirstOrNull()) { + "No scripted result for ${request.operation.name}(${request.variables})." + } + } +} + +private const val SAMPLE_TIMEOUT_MILLIS = 20_000L diff --git a/store6-graphql/src/androidMain/AndroidManifest.xml b/store6-graphql/src/androidMain/AndroidManifest.xml new file mode 100644 index 000000000..8072ee00d --- /dev/null +++ b/store6-graphql/src/androidMain/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/store6-graphql/src/commonMain/kotlin/org/mobilenativefoundation/store6/graphql/Canonicalization.kt b/store6-graphql/src/commonMain/kotlin/org/mobilenativefoundation/store6/graphql/Canonicalization.kt new file mode 100644 index 000000000..0ba8f6250 --- /dev/null +++ b/store6-graphql/src/commonMain/kotlin/org/mobilenativefoundation/store6/graphql/Canonicalization.kt @@ -0,0 +1,71 @@ +@file:OptIn(ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.graphql + +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi + +// Canonical rendering rules (pinned by GraphQlCanonicalizationTest): +// - JSON-shaped, no whitespace. +// - Object keys sorted by UTF-16 code-unit order; list order preserved. +// - Strings JSON-escaped: `"` and `\` prefixed, short escapes \b \f \n \r \t, remaining +// control characters below U+0020 as lowercase \u00xx. +// - Explicit NullValue renders `null`; an absent variable renders nothing, so the two are +// distinct identities. +// - IntValue renders as a decimal integer; FloatValue delegates to Double.toString, which is +// runtime-dependent (documented on GraphQlValue.FloatValue). + +internal fun GraphQlVariables.canonicalString(): String = + buildString { appendCanonicalObject(entries) } + +private fun StringBuilder.appendCanonical(value: GraphQlValue) { + when (value) { + is GraphQlValue.NullValue -> append("null") + is GraphQlValue.BooleanValue -> append(value.value) + is GraphQlValue.IntValue -> append(value.value) + is GraphQlValue.FloatValue -> append(value.value) + is GraphQlValue.StringValue -> appendJsonEscaped(value.value) + is GraphQlValue.ListValue -> { + append('[') + value.values.forEachIndexed { index, entry -> + if (index > 0) append(',') + appendCanonical(entry) + } + append(']') + } + is GraphQlValue.ObjectValue -> appendCanonicalObject(value.fields) + } +} + +private fun StringBuilder.appendCanonicalObject(fields: Map) { + append('{') + fields.keys.sorted().forEachIndexed { index, key -> + if (index > 0) append(',') + appendJsonEscaped(key) + append(':') + appendCanonical(fields.getValue(key)) + } + append('}') +} + +private fun StringBuilder.appendJsonEscaped(value: String) { + append('"') + for (character in value) { + when (character) { + '"' -> append("\\\"") + '\\' -> append("\\\\") + '\b' -> append("\\b") + '\u000C' -> append("\\f") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> + if (character < ' ') { + append("\\u") + append(character.code.toString(16).padStart(4, '0')) + } else { + append(character) + } + } + } + append('"') +} diff --git a/store6-graphql/src/commonMain/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlError.kt b/store6-graphql/src/commonMain/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlError.kt new file mode 100644 index 000000000..090ea5028 --- /dev/null +++ b/store6-graphql/src/commonMain/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlError.kt @@ -0,0 +1,84 @@ +package org.mobilenativefoundation.store6.graphql + +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi + +/** + * One entry of a GraphQL response's `errors` array. + * + * @property message the server-provided error message + * @property path the response field path the error applies to; empty for request-level errors + */ +@ExperimentalStoreApi +public class GraphQlError( + public val message: String, + public val path: List = emptyList(), +) { + /** One step of a response field path. */ + public sealed interface PathSegment { + /** A field name step. */ + public class Field( + /** The field name. */ + public val name: String, + ) : PathSegment { + override fun equals(other: Any?): Boolean = other is Field && other.name == name + + override fun hashCode(): Int = name.hashCode() + + override fun toString(): String = name + } + + /** A list index step. */ + public class Index( + /** The zero-based list index. */ + public val index: Int, + ) : PathSegment { + override fun equals(other: Any?): Boolean = other is Index && other.index == index + + override fun hashCode(): Int = index.hashCode() + + override fun toString(): String = "[$index]" + } + } + + override fun equals(other: Any?): Boolean = + other is GraphQlError && other.message == message && other.path == path + + override fun hashCode(): Int = 31 * message.hashCode() + path.hashCode() + + override fun toString(): String = + if (path.isEmpty()) "GraphQlError($message)" else "GraphQlError($message at ${renderPath(path)})" +} + +/** + * The failure carried as [org.mobilenativefoundation.store6.core.StoreError.Fetch] cause when a + * GraphQL response reports errors the fetcher does not adopt. + * + * @property operationName the operation whose response carried the errors + * @property errors every decoded response error, in response order + */ +@ExperimentalStoreApi +public class GraphQlOperationException( + public val operationName: String, + public val errors: List, +) : Exception(buildOperationExceptionMessage(operationName, errors)) + +@OptIn(ExperimentalStoreApi::class) +private fun buildOperationExceptionMessage( + operationName: String, + errors: List, +): String { + val first = errors.firstOrNull() ?: return "GraphQL operation '$operationName' failed." + val location = if (first.path.isEmpty()) "" else " at ${renderPath(first.path)}" + val remainder = if (errors.size > 1) "; errors carries the remaining ${errors.size - 1}" else "" + return "GraphQL operation '$operationName' returned ${errors.size} error(s): " + + "'${first.message}'$location$remainder." +} + +@OptIn(ExperimentalStoreApi::class) +private fun renderPath(path: List): String = + buildString { + path.forEachIndexed { index, segment -> + if (index > 0 && segment is GraphQlError.PathSegment.Field) append('.') + append(segment) + } + } diff --git a/store6-graphql/src/commonMain/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlExecutor.kt b/store6-graphql/src/commonMain/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlExecutor.kt new file mode 100644 index 000000000..436199eab --- /dev/null +++ b/store6-graphql/src/commonMain/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlExecutor.kt @@ -0,0 +1,76 @@ +package org.mobilenativefoundation.store6.graphql + +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi + +/** + * Executes GraphQL operations against a transport chosen by the caller. + * + * The executor owns everything Store deliberately does not: JSON encoding of + * [GraphQlRequest.variables], the HTTP or WebSocket transport, decoding the response `data` + * into `V`, and translating the response `errors` array into [GraphQlError] values. Throw for + * transport failures (the fetcher maps the thrown exception to a fetch error); return + * [GraphQlExecutorResult.Data] for any response the server produced, even an all-errors one. + * + * When [GraphQlRequest.etag] is non-null the store engine selected a conditional plan: an + * executor that supports revalidation (for example via HTTP `If-None-Match`) may return + * [GraphQlExecutorResult.NotModified] to confirm the cached value. Executors without + * revalidation support can ignore the ETag and execute normally. + * + * @param V the decoded value type produced for successful responses + */ +@ExperimentalStoreApi +public fun interface GraphQlExecutor { + /** Executes [request] and returns the decoded outcome; throws on transport failure. */ + public suspend fun execute(request: GraphQlRequest): GraphQlExecutorResult +} + +/** + * One operation execution handed to a [GraphQlExecutor]. + * + * @property operation the operation to execute; its document is carried unparsed + * @property variables the variables of this execution, taken from the fetched key + * @property etag the ETag of the cached value when the engine selected a conditional plan, + * or null for an unconditional fetch + */ +@ExperimentalStoreApi +public class GraphQlRequest( + public val operation: GraphQlOperation, + public val variables: GraphQlVariables, + public val etag: String?, +) { + override fun toString(): String = "GraphQlRequest(${operation.name}, etag=$etag)" +} + +/** + * The outcome vocabulary a [GraphQlExecutor] returns to the fetcher. + * + * @param V the decoded value type + */ +@ExperimentalStoreApi +public sealed interface GraphQlExecutorResult { + /** + * A GraphQL response: decoded `data`, decoded `errors`, or both. + * + * A response with a non-null [data] and no [errors] maps to a fetch success. Non-empty + * [errors] map per the fetcher's [GraphQlPartialDataPolicy]. Null [data] with no [errors] + * is a protocol violation the fetcher reports as an error. + * + * @property data the decoded response data, or null when the response carried none + * @property errors the decoded response errors; empty when the response carried none + * @property etag the value identity to record for conditional refetching, or null + */ + public class Data( + public val data: V?, + public val errors: List = emptyList(), + public val etag: String? = null, + ) : GraphQlExecutorResult + + /** + * Confirmation that the value identified by [GraphQlRequest.etag] is still current. + * + * @property etag the refreshed ETag, or null to keep the previously recorded tag + */ + public class NotModified( + public val etag: String? = null, + ) : GraphQlExecutorResult +} diff --git a/store6-graphql/src/commonMain/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlFetcher.kt b/store6-graphql/src/commonMain/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlFetcher.kt new file mode 100644 index 000000000..fe43d2d32 --- /dev/null +++ b/store6-graphql/src/commonMain/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlFetcher.kt @@ -0,0 +1,108 @@ +package org.mobilenativefoundation.store6.graphql + +import kotlinx.coroutines.CancellationException +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreBuilder +import org.mobilenativefoundation.store6.core.seam.Fetcher +import org.mobilenativefoundation.store6.core.seam.FetcherResult + +/** + * Creates a [Fetcher] that executes [operation] through [executor] for every fetched key. + * + * The returned fetcher serves one operation: a [GraphQlOperationKey] whose + * [GraphQlOperationKey.operationName] differs from [operation]'s name fails the fetch without + * executing. Executor outcomes map to the fetch vocabulary as follows: a thrown exception + * (other than [CancellationException], which propagates) becomes a fetch error; a + * [GraphQlExecutorResult.NotModified] becomes [FetcherResult.NotModified]; a response with + * non-null data and no errors becomes [FetcherResult.Success]; a response with errors follows + * [partialDataPolicy]; and a response with neither data nor errors is reported as a protocol + * violation. + * + * @param V the decoded value type cached by the store + * @param operation the operation every fetched key must belong to + * @param partialDataPolicy how responses with both data and errors are treated + * @param executor the transport owned by the caller + * @return a fetcher for installation via [StoreBuilder] + */ +@ExperimentalStoreApi +public fun graphQlFetcher( + operation: GraphQlOperation, + partialDataPolicy: GraphQlPartialDataPolicy = GraphQlPartialDataPolicy.FailOnErrors, + executor: GraphQlExecutor, +): Fetcher = GraphQlFetcher(operation, partialDataPolicy, executor) + +/** + * Installs a [graphQlFetcher] built from [operation], [partialDataPolicy], and [executor] as + * this store's fetch source. The last fetcher registration wins, matching the other + * [StoreBuilder] fetcher install points. + * + * @param V the decoded value type cached by the store + * @param operation the operation every fetched key must belong to + * @param partialDataPolicy how responses with both data and errors are treated + * @param executor the transport owned by the caller + */ +@ExperimentalStoreApi +public fun StoreBuilder.graphQlFetcher( + operation: GraphQlOperation, + partialDataPolicy: GraphQlPartialDataPolicy = GraphQlPartialDataPolicy.FailOnErrors, + executor: GraphQlExecutor, +) { + fetcher(GraphQlFetcher(operation, partialDataPolicy, executor)) +} + +@OptIn(DelicateStoreApi::class) +@ExperimentalStoreApi +private class GraphQlFetcher( + private val operation: GraphQlOperation, + private val partialDataPolicy: GraphQlPartialDataPolicy, + private val executor: GraphQlExecutor, +) : Fetcher { + override suspend fun fetch( + key: GraphQlOperationKey, + etag: String?, + ): FetcherResult { + if (key.operationName != operation.name) { + return FetcherResult.Error( + IllegalArgumentException( + "graphQlFetcher for operation '${operation.name}' received a key for " + + "operation '${key.operationName}'. A store serves one operation; build " + + "keys with GraphQlOperation.key(...) on the fetcher's operation.", + ), + ) + } + val result = + try { + executor.execute(GraphQlRequest(operation, key.variables, etag)) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (transportFailure: Throwable) { + return FetcherResult.Error(transportFailure) + } + return when (result) { + is GraphQlExecutorResult.NotModified -> FetcherResult.NotModified(result.etag) + is GraphQlExecutorResult.Data -> mapData(result) + } + } + + private fun mapData(result: GraphQlExecutorResult.Data): FetcherResult { + val data = result.data + if (result.errors.isEmpty()) { + return if (data != null) { + FetcherResult.Success(data, result.etag) + } else { + FetcherResult.Error( + IllegalStateException( + "GraphQL executor for operation '${operation.name}' returned neither " + + "data nor errors; a GraphQL response must carry at least one of " + + "the two.", + ), + ) + } + } + if (data != null && partialDataPolicy == GraphQlPartialDataPolicy.AdoptPartialData) { + return FetcherResult.Success(data, result.etag) + } + return FetcherResult.Error(GraphQlOperationException(operation.name, result.errors)) + } +} diff --git a/store6-graphql/src/commonMain/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlOperation.kt b/store6-graphql/src/commonMain/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlOperation.kt new file mode 100644 index 000000000..095556a14 --- /dev/null +++ b/store6-graphql/src/commonMain/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlOperation.kt @@ -0,0 +1,40 @@ +package org.mobilenativefoundation.store6.graphql + +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi + +/** + * One GraphQL operation: an executable document and the name of the operation to run. + * + * The document is opaque to Store — it is carried to the [GraphQlExecutor] unparsed, so any + * source of documents (hand-written strings, generated constants, persisted-query ids embedded + * by the executor) works. [name] must match the operation name used in [GraphQlOperationKey] + * instances fetched through this operation; [graphQlFetcher] rejects mismatched keys. + * + * @throws IllegalArgumentException if [document] or [name] is blank + */ +@ExperimentalStoreApi +public class GraphQlOperation( + /** The executable GraphQL document, passed to the executor unparsed. */ + public val document: String, + /** The name of the operation to execute from [document]. */ + public val name: String, +) { + init { + require(document.isNotBlank()) { "GraphQlOperation requires a non-blank document." } + require(name.isNotBlank()) { + "GraphQlOperation requires a non-blank name; use the operation's declared name so " + + "keys and fetchers can be matched." + } + } + + /** + * Returns the [GraphQlOperationKey] for executing this operation with [variables]. + * + * @param variables the execution's variables; defaults to none + * @return a key whose operation name is [name] and whose namespace is the key default + */ + public fun key(variables: GraphQlVariables = GraphQlVariables.Empty): GraphQlOperationKey = + GraphQlOperationKey(operationName = name, variables = variables) + + override fun toString(): String = "GraphQlOperation($name)" +} diff --git a/store6-graphql/src/commonMain/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlOperationKey.kt b/store6-graphql/src/commonMain/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlOperationKey.kt new file mode 100644 index 000000000..84b619a89 --- /dev/null +++ b/store6-graphql/src/commonMain/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlOperationKey.kt @@ -0,0 +1,61 @@ +package org.mobilenativefoundation.store6.graphql + +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace + +/** + * A [StoreKey] identifying one GraphQL operation execution: the operation name plus its + * variables. + * + * A store keyed this way is a document cache — each distinct variable set caches one response + * value. Identity is structural: two keys with the same [operationName], equal [variables], + * and the same namespace value are the same cache entry regardless of variable insertion + * order. + * + * @throws IllegalArgumentException if [operationName] is blank + */ +@ExperimentalStoreApi +public class GraphQlOperationKey( + /** The GraphQL operation name this key belongs to. */ + public val operationName: String, + /** The variables of this execution; defaults to none. */ + public val variables: GraphQlVariables = GraphQlVariables.Empty, + /** The key space; defaults to `graphql:`. */ + override val namespace: StoreNamespace = StoreNamespace("graphql:$operationName"), +) : StoreKey { + init { + require(operationName.isNotBlank()) { + "GraphQlOperationKey requires a non-blank operationName; use the operation's " + + "declared name so keys and fetchers can be matched." + } + } + + /** + * Returns `()`. + * + * The variable rendering is JSON-shaped with object keys sorted in UTF-16 code-unit order, + * no whitespace, JSON string escaping, significant list order, and explicit `null` distinct + * from an absent variable. [GraphQlValue.FloatValue] renders through the runtime's + * `Double.toString`, which differs across Kotlin targets; prefer int or string variables + * when canonical ids must match across runtimes. + * + * @return the stable identifier for this key within [namespace] + */ + override fun canonicalId(): String = "$operationName(${variables.canonicalString()})" + + override fun equals(other: Any?): Boolean = + other is GraphQlOperationKey && + other.operationName == operationName && + other.variables == variables && + other.namespace.value == namespace.value + + override fun hashCode(): Int { + var result = operationName.hashCode() + result = 31 * result + variables.hashCode() + result = 31 * result + namespace.value.hashCode() + return result + } + + override fun toString(): String = "GraphQlOperationKey(${namespace.value}/${canonicalId()})" +} diff --git a/store6-graphql/src/commonMain/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlPartialDataPolicy.kt b/store6-graphql/src/commonMain/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlPartialDataPolicy.kt new file mode 100644 index 000000000..ab9162df6 --- /dev/null +++ b/store6-graphql/src/commonMain/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlPartialDataPolicy.kt @@ -0,0 +1,25 @@ +package org.mobilenativefoundation.store6.graphql + +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi + +/** + * How [graphQlFetcher] treats a response that carries both non-null data and errors. + * + * Responses with null data and non-empty errors always fail regardless of policy. + */ +@ExperimentalStoreApi +public sealed interface GraphQlPartialDataPolicy { + /** + * The default: any response error fails the fetch with a [GraphQlOperationException], and + * partial data is discarded. Choose this when a cached value must never contain + * error-substituted nulls. + */ + public data object FailOnErrors : GraphQlPartialDataPolicy + + /** + * Keeps the partial data as a fetch success and drops the errors. Choose this when the + * decoded type tolerates missing fields; the store then caches the partial value as if it + * were complete. + */ + public data object AdoptPartialData : GraphQlPartialDataPolicy +} diff --git a/store6-graphql/src/commonMain/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlValue.kt b/store6-graphql/src/commonMain/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlValue.kt new file mode 100644 index 000000000..1c6416589 --- /dev/null +++ b/store6-graphql/src/commonMain/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlValue.kt @@ -0,0 +1,278 @@ +package org.mobilenativefoundation.store6.graphql + +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi + +/** + * A GraphQL input value used as an operation variable. + * + * The hierarchy mirrors the GraphQL input value grammar without depending on a serialization + * library: the caller's [GraphQlExecutor] owns the wire encoding, while Store uses these values + * only to derive stable cache identity. Two values are equal when they are structurally equal; + * [ObjectValue] fields compare independently of insertion order and [ListValue] entries compare + * in order. + */ +@ExperimentalStoreApi +public sealed interface GraphQlValue { + /** The explicit GraphQL `null` literal, distinct from an absent variable. */ + public data object NullValue : GraphQlValue + + /** A GraphQL `Boolean` value. */ + public class BooleanValue( + /** The wrapped boolean. */ + public val value: Boolean, + ) : GraphQlValue { + override fun equals(other: Any?): Boolean = other is BooleanValue && other.value == value + + override fun hashCode(): Int = value.hashCode() + + override fun toString(): String = "BooleanValue($value)" + } + + /** + * A GraphQL `Int` value, widened to [Long] so 64-bit identifiers keep exact identity. + * + * [IntValue] and [FloatValue] are never equal, matching the GraphQL distinction between + * `Int` and `Float` inputs. + */ + public class IntValue( + /** The wrapped integer. */ + public val value: Long, + ) : GraphQlValue { + override fun equals(other: Any?): Boolean = other is IntValue && other.value == value + + override fun hashCode(): Int = value.hashCode() + + override fun toString(): String = "IntValue($value)" + } + + /** + * A GraphQL `Float` value. + * + * Canonical rendering uses the runtime's `Double.toString`, which differs across Kotlin + * targets (JS drops the trailing `.0` of whole numbers). Prefer [IntValue] or [StringValue] + * variables when canonical ids must match across runtimes, for example in a shared + * persistent cache. + */ + public class FloatValue( + /** The wrapped floating-point number. */ + public val value: Double, + ) : GraphQlValue { + override fun equals(other: Any?): Boolean = other is FloatValue && other.value == value + + override fun hashCode(): Int = value.hashCode() + + override fun toString(): String = "FloatValue($value)" + } + + /** A GraphQL `String` value. */ + public class StringValue( + /** The wrapped string. */ + public val value: String, + ) : GraphQlValue { + override fun equals(other: Any?): Boolean = other is StringValue && other.value == value + + override fun hashCode(): Int = value.hashCode() + + override fun toString(): String = "StringValue($value)" + } + + /** A GraphQL list value; entry order is significant for equality and cache identity. */ + public class ListValue( + values: List, + ) : GraphQlValue { + /** The entries in declaration order. */ + public val values: List = values.toList() + + override fun equals(other: Any?): Boolean = other is ListValue && other.values == values + + override fun hashCode(): Int = values.hashCode() + + override fun toString(): String = "ListValue($values)" + } + + /** A GraphQL input object value; field order is irrelevant for equality and cache identity. */ + public class ObjectValue( + fields: Map, + ) : GraphQlValue { + /** The fields of this object. */ + public val fields: Map = fields.toMap() + + override fun equals(other: Any?): Boolean = other is ObjectValue && other.fields == fields + + override fun hashCode(): Int = fields.hashCode() + + override fun toString(): String = "ObjectValue($fields)" + } +} + +/** + * The named variables of one GraphQL operation execution. + * + * Variables are structural: two instances with equal entry maps are equal regardless of + * insertion order, and they render to the same canonical form in + * [GraphQlOperationKey.canonicalId]. Build instances with [graphQlVariables] or wrap an + * existing map directly. + */ +@ExperimentalStoreApi +public class GraphQlVariables( + entries: Map, +) { + /** The variable bindings by name. */ + public val entries: Map = entries.toMap() + + override fun equals(other: Any?): Boolean = other is GraphQlVariables && other.entries == entries + + override fun hashCode(): Int = entries.hashCode() + + override fun toString(): String = "GraphQlVariables(${canonicalString()})" + + public companion object { + /** The empty variable set, rendered canonically as `{}`. */ + public val Empty: GraphQlVariables = GraphQlVariables(emptyMap()) + } +} + +/** + * Builds a [GraphQlVariables] instance. + * + * @param build configuration applied to a fresh [GraphQlObjectBuilder] + * @return the built variables + */ +@ExperimentalStoreApi +public fun graphQlVariables(build: GraphQlObjectBuilder.() -> Unit): GraphQlVariables = + GraphQlVariables(GraphQlObjectBuilder().apply(build).buildFields()) + +/** + * Builder receiver for GraphQL object structures: the top level of [graphQlVariables] and + * nested [GraphQlValue.ObjectValue] fields. + * + * A repeated `put` for one name replaces the earlier binding. + */ +@ExperimentalStoreApi +public class GraphQlObjectBuilder internal constructor() { + private val fields = LinkedHashMap() + + /** Binds [name] to [value]. */ + public fun put( + name: String, + value: GraphQlValue, + ) { + fields[name] = value + } + + /** Binds [name] to a [GraphQlValue.StringValue]. */ + public fun put( + name: String, + value: String, + ) { + put(name, GraphQlValue.StringValue(value)) + } + + /** Binds [name] to a [GraphQlValue.IntValue]. */ + public fun put( + name: String, + value: Int, + ) { + put(name, GraphQlValue.IntValue(value.toLong())) + } + + /** Binds [name] to a [GraphQlValue.IntValue]. */ + public fun put( + name: String, + value: Long, + ) { + put(name, GraphQlValue.IntValue(value)) + } + + /** Binds [name] to a [GraphQlValue.FloatValue]; see its cross-runtime rendering caveat. */ + public fun put( + name: String, + value: Double, + ) { + put(name, GraphQlValue.FloatValue(value)) + } + + /** Binds [name] to a [GraphQlValue.BooleanValue]. */ + public fun put( + name: String, + value: Boolean, + ) { + put(name, GraphQlValue.BooleanValue(value)) + } + + /** Binds [name] to the explicit GraphQL `null` literal. */ + public fun putNull(name: String) { + put(name, GraphQlValue.NullValue) + } + + /** Binds [name] to a nested [GraphQlValue.ObjectValue] configured by [build]. */ + public fun putObject( + name: String, + build: GraphQlObjectBuilder.() -> Unit, + ) { + put(name, GraphQlValue.ObjectValue(GraphQlObjectBuilder().apply(build).buildFields())) + } + + /** Binds [name] to a nested [GraphQlValue.ListValue] configured by [build]. */ + public fun putList( + name: String, + build: GraphQlListBuilder.() -> Unit, + ) { + put(name, GraphQlValue.ListValue(GraphQlListBuilder().apply(build).buildValues())) + } + + internal fun buildFields(): Map = fields.toMap() +} + +/** Builder receiver for [GraphQlValue.ListValue] entries; entries keep insertion order. */ +@ExperimentalStoreApi +public class GraphQlListBuilder internal constructor() { + private val values = mutableListOf() + + /** Appends [value]. */ + public fun add(value: GraphQlValue) { + values.add(value) + } + + /** Appends a [GraphQlValue.StringValue]. */ + public fun add(value: String) { + add(GraphQlValue.StringValue(value)) + } + + /** Appends a [GraphQlValue.IntValue]. */ + public fun add(value: Int) { + add(GraphQlValue.IntValue(value.toLong())) + } + + /** Appends a [GraphQlValue.IntValue]. */ + public fun add(value: Long) { + add(GraphQlValue.IntValue(value)) + } + + /** Appends a [GraphQlValue.FloatValue]; see its cross-runtime rendering caveat. */ + public fun add(value: Double) { + add(GraphQlValue.FloatValue(value)) + } + + /** Appends a [GraphQlValue.BooleanValue]. */ + public fun add(value: Boolean) { + add(GraphQlValue.BooleanValue(value)) + } + + /** Appends the explicit GraphQL `null` literal. */ + public fun addNull() { + add(GraphQlValue.NullValue) + } + + /** Appends a nested [GraphQlValue.ObjectValue] configured by [build]. */ + public fun addObject(build: GraphQlObjectBuilder.() -> Unit) { + add(GraphQlValue.ObjectValue(GraphQlObjectBuilder().apply(build).buildFields())) + } + + /** Appends a nested [GraphQlValue.ListValue] configured by [build]. */ + public fun addList(build: GraphQlListBuilder.() -> Unit) { + add(GraphQlValue.ListValue(GraphQlListBuilder().apply(build).buildValues())) + } + + internal fun buildValues(): List = values.toList() +} diff --git a/store6-graphql/src/commonTest/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlCanonicalizationTest.kt b/store6-graphql/src/commonTest/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlCanonicalizationTest.kt new file mode 100644 index 000000000..25c41fbf4 --- /dev/null +++ b/store6-graphql/src/commonTest/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlCanonicalizationTest.kt @@ -0,0 +1,281 @@ +@file:OptIn(ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.graphql + +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreNamespace +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class GraphQlCanonicalizationTest { + @Test + fun canonicalId_pinsNameAndSortedVariableRendering() { + val key = + GraphQlOperationKey( + operationName = "GetUser", + variables = + graphQlVariables { + put("limit", 10) + put("id", "42") + }, + ) + + assertEquals("GetUser({\"id\":\"42\",\"limit\":10})", key.canonicalId()) + } + + @Test + fun canonicalId_emptyVariablesRenderAsEmptyObject() { + val key = GraphQlOperationKey(operationName = "GetUser") + + assertEquals("GetUser({})", key.canonicalId()) + } + + @Test + fun canonicalId_topLevelInsertionOrderIsIrrelevant() { + val ab = + GraphQlOperationKey( + operationName = "Search", + variables = + graphQlVariables { + put("a", 1) + put("b", 2) + }, + ) + val ba = + GraphQlOperationKey( + operationName = "Search", + variables = + graphQlVariables { + put("b", 2) + put("a", 1) + }, + ) + + assertEquals(ab.canonicalId(), ba.canonicalId()) + assertEquals(ab, ba) + assertEquals(ab.hashCode(), ba.hashCode()) + } + + @Test + fun canonicalId_nestedObjectKeysAreSortedRecursively() { + val key = + GraphQlOperationKey( + operationName = "Search", + variables = + graphQlVariables { + putObject("filter") { + put("b", 1) + put("a", 2) + } + }, + ) + + assertEquals("Search({\"filter\":{\"a\":2,\"b\":1}})", key.canonicalId()) + } + + @Test + fun canonicalId_objectKeysSortByUtf16CodeUnitOrder() { + // 'Z' (0x5A) < '_' (0x5F) < 'a' (0x61): code-unit order, not case-insensitive order. + val key = + GraphQlOperationKey( + operationName = "Q", + variables = + graphQlVariables { + put("a", 1) + put("Z", 2) + put("_", 3) + }, + ) + + assertEquals("Q({\"Z\":2,\"_\":3,\"a\":1})", key.canonicalId()) + } + + @Test + fun canonicalId_listOrderIsSignificant() { + val oneTwo = + GraphQlOperationKey( + operationName = "Q", + variables = + graphQlVariables { + putList("ids") { + add(1) + add(2) + } + }, + ) + val twoOne = + GraphQlOperationKey( + operationName = "Q", + variables = + graphQlVariables { + putList("ids") { + add(2) + add(1) + } + }, + ) + + assertEquals("Q({\"ids\":[1,2]})", oneTwo.canonicalId()) + assertEquals("Q({\"ids\":[2,1]})", twoOne.canonicalId()) + assertNotEquals(oneTwo, twoOne) + } + + @Test + fun canonicalId_stringsUseJsonEscaping() { + val key = + GraphQlOperationKey( + operationName = "Q", + variables = + graphQlVariables { + put("s", "he said \"hi\"\nthen\ta\\slash") + }, + ) + + assertEquals("Q({\"s\":\"he said \\\"hi\\\"\\nthen\\ta\\\\slash\"})", key.canonicalId()) + } + + @Test + fun canonicalId_controlCharactersUseShortOrHexEscapes() { + val key = + GraphQlOperationKey( + operationName = "Q", + variables = + graphQlVariables { + put("s", "\b\u000C\n\r\t\u0000\u001F") + }, + ) + + assertEquals("Q({\"s\":\"\\b\\f\\n\\r\\t\\u0000\\u001f\"})", key.canonicalId()) + } + + @Test + fun canonicalId_explicitNullIsDistinctFromAbsent() { + val explicitNull = + GraphQlOperationKey( + operationName = "Q", + variables = graphQlVariables { putNull("cursor") }, + ) + val absent = GraphQlOperationKey(operationName = "Q") + + assertEquals("Q({\"cursor\":null})", explicitNull.canonicalId()) + assertEquals("Q({})", absent.canonicalId()) + assertNotEquals(explicitNull, absent) + } + + @Test + fun canonicalId_scalarRenderings() { + val key = + GraphQlOperationKey( + operationName = "Q", + variables = + graphQlVariables { + put("yes", true) + put("no", false) + put("big", Long.MAX_VALUE) + put("half", 1.5) + }, + ) + + assertEquals( + "Q({\"big\":9223372036854775807,\"half\":1.5,\"no\":false,\"yes\":true})", + key.canonicalId(), + ) + } + + @Test + fun canonicalId_duplicatePutsLastWins() { + val key = + GraphQlOperationKey( + operationName = "Q", + variables = + graphQlVariables { + put("a", 1) + put("a", 2) + }, + ) + + assertEquals("Q({\"a\":2})", key.canonicalId()) + } + + @Test + fun canonicalId_nestedListsAndObjectsCompose() { + val key = + GraphQlOperationKey( + operationName = "Q", + variables = + graphQlVariables { + putList("rows") { + addObject { + put("y", 1) + put("x", 2) + } + addList { + add("s") + addNull() + } + } + }, + ) + + assertEquals("Q({\"rows\":[{\"x\":2,\"y\":1},[\"s\",null]]})", key.canonicalId()) + } + + @Test + fun namespace_defaultsToGraphqlPrefixedOperationName() { + val key = GraphQlOperationKey(operationName = "GetUser") + + assertEquals("graphql:GetUser", key.namespace.value) + } + + @Test + fun namespace_customNamespaceIsPreservedAndPartOfIdentity() { + val custom = + GraphQlOperationKey( + operationName = "GetUser", + namespace = StoreNamespace("tenant-a"), + ) + val default = GraphQlOperationKey(operationName = "GetUser") + + assertEquals("tenant-a", custom.namespace.value) + assertNotEquals(custom, default) + } + + @Test + fun variables_equalityIsStructural() { + val ab = + graphQlVariables { + put("a", 1) + put("b", "x") + } + val ba = + graphQlVariables { + put("b", "x") + put("a", 1) + } + + assertEquals(ab, ba) + assertEquals(ab.hashCode(), ba.hashCode()) + assertNotEquals(ab, graphQlVariables { put("a", 1) }) + } + + @Test + fun values_intAndFloatAreDistinct() { + assertNotEquals(GraphQlValue.IntValue(1), GraphQlValue.FloatValue(1.0)) + assertEquals(GraphQlValue.IntValue(1), GraphQlValue.IntValue(1)) + } + + @Test + fun operationKeyHelper_carriesOperationNameAndVariables() { + val operation = + GraphQlOperation( + document = "query GetUser(\$id: ID!) { user(id: \$id) { name } }", + name = "GetUser", + ) + val key = operation.key(graphQlVariables { put("id", "42") }) + + assertEquals("GetUser", key.operationName) + assertEquals("GetUser({\"id\":\"42\"})", key.canonicalId()) + assertEquals("graphql:GetUser", key.namespace.value) + } +} diff --git a/store6-graphql/src/commonTest/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlFetcherMappingTest.kt b/store6-graphql/src/commonTest/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlFetcherMappingTest.kt new file mode 100644 index 000000000..3993bc07a --- /dev/null +++ b/store6-graphql/src/commonTest/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlFetcherMappingTest.kt @@ -0,0 +1,252 @@ +@file:OptIn(ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.graphql + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +class GraphQlFetcherMappingTest { + private data class User(val id: String, val name: String) + + private val operation = + GraphQlOperation( + document = "query GetUser(\$id: ID!) { user(id: \$id) { name } }", + name = "GetUser", + ) + private val key = operation.key(graphQlVariables { put("id", "42") }) + private val user = User(id = "42", name = "Ada") + + @Test + fun data_withoutErrors_mapsToSuccessWithEtag() = runTest { + val fetcher = + graphQlFetcher(operation) { _ -> + GraphQlExecutorResult.Data(data = user, etag = "v1") + } + + val result = fetcher.fetch(key, etag = null) + + val success = assertIs>(result) + assertSame(user, success.value) + assertEquals("v1", success.etag) + } + + @Test + fun data_withoutEtag_mapsToSuccessWithNullEtag() = runTest { + val fetcher = + graphQlFetcher(operation) { _ -> + GraphQlExecutorResult.Data(data = user) + } + + val success = assertIs>(fetcher.fetch(key, etag = null)) + assertNull(success.etag) + } + + @Test + fun notModified_mapsToFetcherNotModifiedWithEtag() = runTest { + val fetcher = + graphQlFetcher(operation) { _ -> + GraphQlExecutorResult.NotModified(etag = "v2") + } + + val result = fetcher.fetch(key, etag = "v1") + + val notModified = assertIs(result) + assertEquals("v2", notModified.etag) + } + + @Test + fun executorThrow_mapsToFetcherErrorWithSameCause() = runTest { + val transportFailure = RuntimeException("socket closed") + val fetcher = + graphQlFetcher(operation) { _ -> + throw transportFailure + } + + val result = fetcher.fetch(key, etag = null) + + val error = assertIs(result) + assertSame(transportFailure, error.cause) + } + + @Test + fun cancellation_propagatesInsteadOfMappingToError() = runTest { + val fetcher = + graphQlFetcher(operation) { _ -> + throw CancellationException("caller cancelled") + } + + assertFailsWith { fetcher.fetch(key, etag = null) } + } + + @Test + fun nullDataWithoutErrors_isProtocolViolationError() = runTest { + val fetcher = + graphQlFetcher(operation) { _ -> + GraphQlExecutorResult.Data(data = null) + } + + val error = assertIs(fetcher.fetch(key, etag = null)) + + val cause = assertIs(error.cause) + assertTrue("GetUser" in cause.message.orEmpty()) + } + + @Test + fun nullDataWithErrors_mapsToOperationException() = runTest { + val executorErrors = + listOf( + GraphQlError( + message = "User not found", + path = + listOf( + GraphQlError.PathSegment.Field("user"), + ), + ), + ) + val fetcher = + graphQlFetcher(operation) { _ -> + GraphQlExecutorResult.Data(data = null, errors = executorErrors) + } + + val error = assertIs(fetcher.fetch(key, etag = null)) + + val cause = assertIs(error.cause) + assertEquals("GetUser", cause.operationName) + assertEquals(executorErrors, cause.errors) + assertTrue("GetUser" in cause.message.orEmpty()) + assertTrue("User not found" in cause.message.orEmpty()) + } + + @Test + fun partialData_defaultPolicy_failsWithOperationException() = runTest { + val executorErrors = listOf(GraphQlError(message = "field resolution failed")) + val fetcher = + graphQlFetcher(operation) { _ -> + GraphQlExecutorResult.Data(data = user, errors = executorErrors) + } + + val error = assertIs(fetcher.fetch(key, etag = null)) + + val cause = assertIs(error.cause) + assertEquals(executorErrors, cause.errors) + } + + @Test + fun partialData_adoptPolicy_mapsToSuccessKeepingData() = runTest { + val fetcher = + graphQlFetcher( + operation = operation, + partialDataPolicy = GraphQlPartialDataPolicy.AdoptPartialData, + ) { _ -> + GraphQlExecutorResult.Data( + data = user, + errors = listOf(GraphQlError(message = "field resolution failed")), + etag = "v3", + ) + } + + val success = assertIs>(fetcher.fetch(key, etag = null)) + assertSame(user, success.value) + assertEquals("v3", success.etag) + } + + @Test + fun adoptPolicy_nullDataWithErrors_stillMapsToOperationException() = runTest { + val fetcher = + graphQlFetcher( + operation = operation, + partialDataPolicy = GraphQlPartialDataPolicy.AdoptPartialData, + ) { _ -> + GraphQlExecutorResult.Data( + data = null, + errors = listOf(GraphQlError(message = "hard failure")), + ) + } + + val error = assertIs(fetcher.fetch(key, etag = null)) + assertIs(error.cause) + } + + @Test + fun keyOperationMismatch_mapsToErrorNamingBothWithoutExecuting() = runTest { + var executed = false + val fetcher = + graphQlFetcher(operation) { _ -> + executed = true + GraphQlExecutorResult.Data(data = user) + } + val mismatchedKey = GraphQlOperationKey(operationName = "GetAccount") + + val error = assertIs(fetcher.fetch(mismatchedKey, etag = null)) + + val cause = assertIs(error.cause) + assertTrue("GetUser" in cause.message.orEmpty()) + assertTrue("GetAccount" in cause.message.orEmpty()) + assertEquals(false, executed) + } + + @Test + fun request_threadsOperationVariablesAndEngineEtag() = runTest { + val requests = mutableListOf() + val fetcher = + graphQlFetcher(operation) { request -> + requests.add(request) + GraphQlExecutorResult.Data(data = user) + } + + fetcher.fetch(key, etag = "engine-etag") + + assertEquals(1, requests.size) + assertSame(operation, requests.single().operation) + assertEquals(key.variables, requests.single().variables) + assertEquals("engine-etag", requests.single().etag) + } + + @Test + fun request_withoutConditionalPlan_hasNullEtag() = runTest { + val requests = mutableListOf() + val fetcher = + graphQlFetcher(operation) { request -> + requests.add(request) + GraphQlExecutorResult.Data(data = user) + } + + fetcher.fetch(key, etag = null) + + assertNull(requests.single().etag) + } + + @Test + fun graphQlError_exposesMessageAndPathSegments() { + val error = + GraphQlError( + message = "boom", + path = + listOf( + GraphQlError.PathSegment.Field("users"), + GraphQlError.PathSegment.Index(0), + GraphQlError.PathSegment.Field("name"), + ), + ) + + assertEquals("boom", error.message) + assertEquals("users", (error.path[0] as GraphQlError.PathSegment.Field).name) + assertEquals(0, (error.path[1] as GraphQlError.PathSegment.Index).index) + assertEquals("name", (error.path[2] as GraphQlError.PathSegment.Field).name) + } +} + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = 25.seconds, testBody = testBody) diff --git a/store6-graphql/src/commonTest/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlStoreIntegrationTest.kt b/store6-graphql/src/commonTest/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlStoreIntegrationTest.kt new file mode 100644 index 000000000..d5147c35b --- /dev/null +++ b/store6-graphql/src/commonTest/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlStoreIntegrationTest.kt @@ -0,0 +1,266 @@ +@file:OptIn(ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.graphql + +import app.cash.turbine.test +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import kotlinx.coroutines.withContext +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreException +import org.mobilenativefoundation.store6.core.StoreResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.test.fail +import kotlin.time.Duration.Companion.seconds + +class GraphQlStoreIntegrationTest { + private val ada = TestUser(id = "1", name = "Ada") + private val revisedAda = TestUser(id = "1", name = "Ada Lovelace") + + @Test + fun documentCache_servesResidentValueWithoutReexecution() = runTest { + val variables = userVariables("1") + val executor = ScriptedGraphQlExecutor() + executor.enqueue(variables, GraphQlExecutorResult.Data(data = ada)) + val store = userStore(executor) + + try { + assertEquals(ada, store.get(GET_USER.key(variables))) + assertEquals(ada, store.get(GET_USER.key(variables))) + assertEquals(1, executor.callCount(variables)) + } finally { + store.close() + } + } + + @Test + fun documentCache_variableOrderDoesNotSplitEntries() = runTest { + val variables = + graphQlVariables { + put("id", "1") + put("locale", "en") + } + val reordered = + graphQlVariables { + put("locale", "en") + put("id", "1") + } + val executor = ScriptedGraphQlExecutor() + executor.enqueue(variables, GraphQlExecutorResult.Data(data = ada)) + val store = userStore(executor) + + try { + assertEquals(ada, store.get(GET_USER.key(variables))) + assertEquals(ada, store.get(GET_USER.key(reordered))) + assertEquals(1, executor.callCount(variables)) + assertEquals(1, executor.callCount(reordered)) + } finally { + store.close() + } + } + + @Test + fun concurrentReaders_shareOneExecution() = runTest { + val variables = userVariables("1") + val executionEntered = CompletableDeferred() + val releaseExecution = CompletableDeferred() + val executor = ScriptedGraphQlExecutor() + executor.enqueueAction(variables) { + executionEntered.complete(Unit) + releaseExecution.await() + GraphQlExecutorResult.Data(data = ada) + } + val store = userStore(executor) + + try { + val first = async { store.get(GET_USER.key(variables)) } + executionEntered.awaitFromDefaultContext() + val second = async { store.get(GET_USER.key(variables)) } + releaseExecution.complete(Unit) + + assertEquals(ada, first.await()) + assertEquals(ada, second.await()) + assertEquals(1, executor.callCount(variables)) + } finally { + releaseExecution.complete(Unit) + store.close() + } + } + + @Test + fun mustBeFresh_reexecutesInsteadOfServingResident() = runTest { + val variables = userVariables("1") + val executor = ScriptedGraphQlExecutor() + executor.enqueue( + variables, + GraphQlExecutorResult.Data(data = ada), + GraphQlExecutorResult.Data(data = revisedAda), + ) + val store = userStore(executor) + + try { + assertEquals(ada, store.get(GET_USER.key(variables))) + assertEquals(revisedAda, store.get(GET_USER.key(variables), Freshness.MustBeFresh)) + assertEquals(2, executor.callCount(variables)) + } finally { + store.close() + } + } + + @Test + fun conditionalRefetch_notModified_emitsRevalidatedAndThreadsEtags() = runTest { + val variables = userVariables("1") + val executor = ScriptedGraphQlExecutor() + executor.enqueue( + variables, + GraphQlExecutorResult.Data(data = ada, etag = "e1"), + // A cold-baseline 304 commits ObsoleteRevalidation and legally self-heals + // with exactly one replanned conditional fetch. + GraphQlExecutorResult.NotModified("e1"), + GraphQlExecutorResult.NotModified("e1"), + ) + val store = userStore(executor) + val key = GET_USER.key(variables) + + try { + store.stream(key).test { + assertIs(awaitItem()) + assertEquals(ada, assertIs>(awaitItem()).value) + store.invalidate(key) + + while (true) { + when (val item = awaitItem()) { + is StoreResult.Data -> { + if (item.origin == Origin.FETCHER && !item.isStale) { + fail("legacy fresh FETCHER Data must be replaced by Revalidated") + } + } + is StoreResult.Revalidated -> break + else -> fail("unexpected lifecycle item ${item::class.simpleName}") + } + } + cancelAndIgnoreRemainingEvents() + } + + val recordedEtags = executor.etags(variables) + assertTrue( + recordedEtags.size in 2..3, + "the 304 cycle may self-heal one obsolete cold-baseline launch", + ) + assertEquals(null, recordedEtags[0]) + recordedEtags.drop(1).forEach { etag -> + assertEquals("e1", etag, "conditional replans must carry the recorded ETag") + } + } finally { + store.close() + } + } + + @Test + fun executorErrors_surfaceAsFetchErrorFramesCarryingOperationException() = runTest { + val variables = userVariables("missing") + val responseErrors = + listOf( + GraphQlError( + message = "User not found", + path = listOf(GraphQlError.PathSegment.Field("user")), + ), + ) + val executor = ScriptedGraphQlExecutor() + executor.enqueue( + variables, + GraphQlExecutorResult.Data(data = null, errors = responseErrors), + ) + val store = userStore(executor) + + try { + store.stream(GET_USER.key(variables)).test { + assertIs(awaitItem()) + val frame = assertIs(awaitItem()) + val fetchError = assertIs(frame.error) + val cause = assertIs(fetchError.cause) + assertEquals("GetUser", cause.operationName) + assertEquals(responseErrors, cause.errors) + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun mustBeFreshGet_failsWithOperationExceptionCause() = runTest { + val variables = userVariables("missing") + val executor = ScriptedGraphQlExecutor() + executor.enqueue( + variables, + GraphQlExecutorResult.Data( + data = null, + errors = listOf(GraphQlError(message = "User not found")), + ), + ) + val store = userStore(executor) + + try { + val failure = + assertFailsWith { + store.get(GET_USER.key(variables), Freshness.MustBeFresh) + } + val fetchError = assertIs(failure.error) + assertIs(fetchError.cause) + } finally { + store.close() + } + } + + @Test + fun adoptPartialData_cachesThePartialValue() = runTest { + val variables = userVariables("1") + val executor = ScriptedGraphQlExecutor() + executor.enqueue( + variables, + GraphQlExecutorResult.Data( + data = ada, + errors = listOf(GraphQlError(message = "avatar resolution failed")), + ), + ) + val store = userStore(executor, GraphQlPartialDataPolicy.AdoptPartialData) + + try { + assertEquals(ada, store.get(GET_USER.key(variables))) + assertEquals(ada, store.get(GET_USER.key(variables))) + assertEquals(1, executor.callCount(variables)) + } finally { + store.close() + } + } +} + +// Turbine's 3s default would nest inside the 25s shadow. Raising the Turbine deadline above +// the shadow makes runTest the only effective timeout. +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds // strictly > TEST_TIMEOUT: the shadow must fire first + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } + +// Preserve Default-dispatch ordering and let the suite-level runTest bound own cancellation. +private suspend fun CompletableDeferred.awaitFromDefaultContext(): T = + withContext(Dispatchers.Default) { + await() + } diff --git a/store6-graphql/src/commonTest/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlTestFixtures.kt b/store6-graphql/src/commonTest/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlTestFixtures.kt new file mode 100644 index 000000000..dd9c83d0f --- /dev/null +++ b/store6-graphql/src/commonTest/kotlin/org/mobilenativefoundation/store6/graphql/GraphQlTestFixtures.kt @@ -0,0 +1,73 @@ +@file:OptIn(ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.graphql + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.store + +internal data class TestUser( + val id: String, + val name: String, +) + +internal val GET_USER = + GraphQlOperation( + document = "query GetUser(\$id: ID!) { user(id: \$id) { id name } }", + name = "GetUser", + ) + +internal fun userVariables(id: String): GraphQlVariables = graphQlVariables { put("id", id) } + +internal class ScriptedGraphQlExecutor : GraphQlExecutor { + private val scripts = + MutableStateFlow GraphQlExecutorResult>>>(emptyMap()) + private val recordedEtags = MutableStateFlow>>(emptyMap()) + + fun enqueue( + variables: GraphQlVariables, + vararg results: GraphQlExecutorResult, + ) { + results.forEach { result -> enqueueAction(variables) { result } } + } + + fun enqueueAction( + variables: GraphQlVariables, + result: suspend (etag: String?) -> GraphQlExecutorResult, + ) { + scripts.update { current -> current + (variables to (current[variables].orEmpty() + result)) } + } + + fun callCount(variables: GraphQlVariables): Int = recordedEtags.value[variables].orEmpty().size + + fun etags(variables: GraphQlVariables): List = recordedEtags.value[variables].orEmpty() + + override suspend fun execute(request: GraphQlRequest): GraphQlExecutorResult { + recordedEtags.update { current -> + current + (request.variables to (current[request.variables].orEmpty() + request.etag)) + } + return pop(request.variables)?.invoke(request.etag) + ?: throw IllegalStateException( + "No scripted GraphQL result for ${request.operation.name}(${request.variables}).", + ) + } + + private fun pop(variables: GraphQlVariables): (suspend (String?) -> GraphQlExecutorResult)? { + while (true) { + val current = scripts.value + val queue = current[variables].orEmpty() + val head = queue.firstOrNull() ?: return null + if (scripts.compareAndSet(current, current + (variables to queue.drop(1)))) return head + } + } +} + +internal fun userStore( + executor: ScriptedGraphQlExecutor, + partialDataPolicy: GraphQlPartialDataPolicy = GraphQlPartialDataPolicy.FailOnErrors, +): Store = + store { + graphQlFetcher(GET_USER, partialDataPolicy, executor) + } diff --git a/store6-mutations-quickstart/build.gradle.kts b/store6-mutations-quickstart/build.gradle.kts new file mode 100644 index 000000000..f5804c19f --- /dev/null +++ b/store6-mutations-quickstart/build.gradle.kts @@ -0,0 +1,10 @@ +plugins { + id("org.jetbrains.kotlin.jvm") + application +} + +kotlin { jvmToolchain(11) } + +dependencies { implementation(projects.store6Mutations) } + +application { mainClass.set("MainKt") } diff --git a/store6-mutations-quickstart/src/main/kotlin/Main.kt b/store6-mutations-quickstart/src/main/kotlin/Main.kt new file mode 100644 index 000000000..8e8697ff5 --- /dev/null +++ b/store6-mutations-quickstart/src/main/kotlin/Main.kt @@ -0,0 +1,136 @@ +// docs:snippet:mutations-quickstart +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.mutations.MutationAck +import org.mobilenativefoundation.store6.mutations.MutationCodec +import org.mobilenativefoundation.store6.mutations.MutationKeyResolver +import org.mobilenativefoundation.store6.mutations.MutationPresence +import org.mobilenativefoundation.store6.mutations.MutationPresentAck +import org.mobilenativefoundation.store6.mutations.MutationPush +import org.mobilenativefoundation.store6.mutations.MutationRetirement +import org.mobilenativefoundation.store6.mutations.MutationRetirementAck +import org.mobilenativefoundation.store6.mutations.MutationServer +import org.mobilenativefoundation.store6.mutations.MutatorRef +import org.mobilenativefoundation.store6.mutations.StaleSet +import org.mobilenativefoundation.store6.mutations.mutationStore +import org.mobilenativefoundation.store6.mutations.mutatorRegistry + +private class UserKey(val id: String) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("users") + override fun canonicalId(): String = id +} + +private data class User(val id: String, val name: String) +private data class Rename(val name: String) + +private object RenameCodec : MutationCodec { + override fun encode(value: Rename): ByteArray = value.name.encodeToByteArray() + + override fun decode(version: Int, bytes: ByteArray): Rename { + require(version == 1) { "Unsupported Rename version: $version" } + return Rename(bytes.decodeToString()) + } +} + +private object UserCodec : MutationCodec { + override fun encode(value: User): ByteArray = + "${value.id}\n${value.name}".encodeToByteArray() + + override fun decode(version: Int, bytes: ByteArray): User { + require(version == 1) { "Unsupported User version: $version" } + val fields = bytes.decodeToString().split('\n', limit = 2) + require(fields.size == 2) { "Malformed User payload" } + return User(id = fields[0], name = fields[1]) + } +} + +private object UserServer : MutationServer { + private val rows = mutableMapOf("42" to User("42", "Ada")) + private val receipts = mutableMapOf>() + + fun load(key: UserKey): User = checkNotNull(rows[key.id]) + + override suspend fun push(request: MutationPush): MutationAck = + receipts.getOrPut(request.idempotencyKey) { + when (val mine = request.mine) { + is MutationPresence.Present -> { + rows[request.identity.canonicalId] = mine.value + MutationPresentAck( + authoritative = mine.value, + etag = null, + canonicalKey = null, + ) + } + MutationPresence.Absent -> error("This example registers no delete mutator.") + } + } + + override suspend fun retire(request: MutationRetirement): MutationRetirementAck = + MutationRetirementAck( + confirmedThroughSequence = request.retiredThroughSequence, + ) +} + +public fun main(): Unit = + runBlocking { + lateinit var renameRef: MutatorRef + val registry = + mutatorRegistry { + renameRef = + update( + id = "rename", + version = 1, + codec = RenameCodec, + stales = { _, _ -> + StaleSet(keys = emptySet(), namespaces = emptySet()) + }, + project = { user, rename -> user.copy(name = rename.name) }, + ) + } + + val users = + mutationStore( + registry = registry, + server = UserServer, + keyResolver = MutationKeyResolver { identity -> UserKey(identity.canonicalId) }, + valueCodecVersion = 1, + valueCodec = UserCodec, + ) { + fetcher { key -> UserServer.load(key) } + } + + try { + val key = UserKey("42") + println("base=${users.get(key).name}") + + val mutationId = users.mutate(key, renameRef, Rename("Grace")) + val optimistic = + users.stream(key) + .filterIsInstance>() + .first { data -> data.origin == Origin.OVERLAY } + println("mutation=$mutationId optimistic=${optimistic.value.name}") + + // get() reads committed truth, so it still returns Ada before drain. + println("committed-before-drain=${users.get(key).name}") + + users.drain(key) + + // Open a new stream after acknowledgement; an already-active collector is not promised + // to converge across that boundary yet. + val confirmed = + users.stream(key) + .filterIsInstance>() + .first { data -> data.origin == Origin.SOT || data.origin == Origin.MEMORY } + println("confirmed=${confirmed.value.name} origin=${confirmed.origin}") + } finally { + users.close() + } + } +// docs:snippet:end diff --git a/store6-mutations-sqldelight/api/android/store6-mutations-sqldelight.api b/store6-mutations-sqldelight/api/android/store6-mutations-sqldelight.api new file mode 100644 index 000000000..484fbd944 --- /dev/null +++ b/store6-mutations-sqldelight/api/android/store6-mutations-sqldelight.api @@ -0,0 +1,5 @@ +public final class org/mobilenativefoundation/store6/mutations/sqldelight/SqlDelightMutationJournalStorage : org/mobilenativefoundation/store6/mutations/storage/MutationJournalStorage { + public fun (Lapp/cash/sqldelight/db/SqlDriver;Lapp/cash/sqldelight/Transacter;)V + public fun transaction (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + diff --git a/store6-mutations-sqldelight/api/jvm/store6-mutations-sqldelight.api b/store6-mutations-sqldelight/api/jvm/store6-mutations-sqldelight.api new file mode 100644 index 000000000..484fbd944 --- /dev/null +++ b/store6-mutations-sqldelight/api/jvm/store6-mutations-sqldelight.api @@ -0,0 +1,5 @@ +public final class org/mobilenativefoundation/store6/mutations/sqldelight/SqlDelightMutationJournalStorage : org/mobilenativefoundation/store6/mutations/storage/MutationJournalStorage { + public fun (Lapp/cash/sqldelight/db/SqlDriver;Lapp/cash/sqldelight/Transacter;)V + public fun transaction (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + diff --git a/store6-mutations-sqldelight/api/store6-mutations-sqldelight.klib.api b/store6-mutations-sqldelight/api/store6-mutations-sqldelight.klib.api new file mode 100644 index 000000000..79f93be54 --- /dev/null +++ b/store6-mutations-sqldelight/api/store6-mutations-sqldelight.klib.api @@ -0,0 +1,13 @@ +// Klib ABI Dump +// Targets: [iosArm64, iosSimulatorArm64, iosX64, js, linuxX64, macosArm64, mingwX64, tvosArm64, wasmJs, watchosArm64] +// Rendering settings: +// - Signature version: 2 +// - Show manifest properties: true +// - Show declarations: true + +// Library unique name: +final class org.mobilenativefoundation.store6.mutations.sqldelight/SqlDelightMutationJournalStorage : org.mobilenativefoundation.store6.mutations.storage/MutationJournalStorage { // org.mobilenativefoundation.store6.mutations.sqldelight/SqlDelightMutationJournalStorage|null[0] + constructor (app.cash.sqldelight.db/SqlDriver, app.cash.sqldelight/Transacter) // org.mobilenativefoundation.store6.mutations.sqldelight/SqlDelightMutationJournalStorage.|(app.cash.sqldelight.db.SqlDriver;app.cash.sqldelight.Transacter){}[0] + + final suspend fun <#A1: kotlin/Any?> transaction(kotlin/Function1): #A1 // org.mobilenativefoundation.store6.mutations.sqldelight/SqlDelightMutationJournalStorage.transaction|transaction(kotlin.Function1){0§}[0] +} diff --git a/store6-mutations-sqldelight/build.gradle.kts b/store6-mutations-sqldelight/build.gradle.kts new file mode 100644 index 000000000..d933814d0 --- /dev/null +++ b/store6-mutations-sqldelight/build.gradle.kts @@ -0,0 +1,127 @@ +import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget +import org.jetbrains.kotlin.gradle.plugin.mpp.TestExecutable +import org.jetbrains.kotlin.gradle.targets.native.tasks.KotlinNativeTest +import org.jetbrains.kotlin.gradle.tasks.KotlinNativeLink +import org.jetbrains.kotlin.konan.target.Family +import org.jetbrains.kotlin.konan.target.HostManager + +plugins { + id("org.mobilenativefoundation.store.store6.multiplatform") +} + +val linuxSqliteLibraryDirectory = providers.exec { + commandLine("pkg-config", "--variable=libdir", "sqlite3") +}.standardOutput.asText.map { output -> + output.trim().also { directory -> + require(directory.isNotEmpty()) { + "pkg-config returned an empty sqlite3 library directory" + } + } +} + +kotlin { + // Mirrors SQLDelight's linkSqlite() without applying its codegen/database plugin. + targets + .filterIsInstance() + .flatMap { it.binaries } + .forEach { binary -> + if ( + binary is TestExecutable && + HostManager.host.family == Family.LINUX && + binary.target.konanTarget.family == Family.LINUX + ) { + // The Linux test runs on the same host that supplies sqlite3. Kotlin/Native's + // glibc-2.19 sysroot cannot resolve the host library's newer indirect GLIBC + // versions at link time; the matching host loader resolves them at execution. + binary.linkerOpts( + "-L${linuxSqliteLibraryDirectory.get()}", + "-lsqlite3", + "--allow-shlib-undefined", + ) + } else { + binary.linkerOpts("-lsqlite3") + } + } + + sourceSets { + val commonMain by getting { + dependencies { + api(projects.store6Mutations) + api(libs.sqldelight.runtime) + } + } + val commonTest by getting { + dependencies { + implementation(projects.store6MutationsTesting) + implementation(libs.kotlinx.coroutines.test) + } + } + val commonSqlTest by creating { + dependsOn(commonTest) + } + val jvmTest by getting { + dependsOn(commonSqlTest) + dependencies { + implementation(libs.sqldelight.sqlite.driver) + } + } + val androidUnitTest by getting { + dependsOn(commonSqlTest) + dependencies { + implementation(libs.sqldelight.sqlite.driver) + } + } + val nativeSqlTest by creating { + dependsOn(commonSqlTest) + dependencies { + implementation(libs.sqldelight.native.driver) + } + } + listOf( + "iosX64Test", + "iosArm64Test", + "iosSimulatorArm64Test", + "macosArm64Test", + "linuxX64Test", + "mingwX64Test", + ).forEach { getByName(it).dependsOn(nativeSqlTest) } + } +} + +android { + namespace = "org.mobilenativefoundation.store6.mutations.sqldelight" +} + +tasks.withType().configureEach { + if (binary is TestExecutable) { + val targetFamily = binary.target.konanTarget.family + onlyIf("native SQLDelight test binaries require the current host SDK's sqlite3") { + val hostFamily = HostManager.host.family + targetFamily == hostFamily || + (hostFamily.isAppleFamily && targetFamily.isAppleFamily) + } + } +} + +tasks.withType().configureEach { + // KGP 2.1.21 can race native-test stdout/stderr handlers and corrupt Gradle + // 8.11.1's captured-output index after an executable succeeds. Keep JUnit + // testcase/failure XML, but do not read the affected output index. + reports.html.required.set(false) + reports.junitXml.apply { + isOutputPerTestCase = false + includeSystemOutLog.set(false) + includeSystemErrLog.set(false) + } + + doLast { + // allTests aggregates binary test results and would reopen the same index. + // Preserve results.bin while omitting only captured output from its report. + listOf("output.bin", "output.bin.idx").forEach { fileName -> + val outputFile = binaryResultsDirectory.file(fileName).get().asFile + check(!outputFile.exists() || outputFile.delete()) { + "Could not remove corrupt Gradle test-output file: $outputFile" + } + } + } +} diff --git a/store6-mutations-sqldelight/gradle.properties b/store6-mutations-sqldelight/gradle.properties new file mode 100644 index 000000000..0aa01c7d9 --- /dev/null +++ b/store6-mutations-sqldelight/gradle.properties @@ -0,0 +1,3 @@ +VERSION_NAME=6.0.0-SNAPSHOT +POM_NAME=store6-mutations-sqldelight +POM_ARTIFACT_ID=store6-mutations-sqldelight diff --git a/store6-mutations-sqldelight/src/androidMain/AndroidManifest.xml b/store6-mutations-sqldelight/src/androidMain/AndroidManifest.xml new file mode 100644 index 000000000..cc947c567 --- /dev/null +++ b/store6-mutations-sqldelight/src/androidMain/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/store6-mutations-sqldelight/src/androidUnitTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/JournalHarness.android.kt b/store6-mutations-sqldelight/src/androidUnitTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/JournalHarness.android.kt new file mode 100644 index 000000000..15f71ad0d --- /dev/null +++ b/store6-mutations-sqldelight/src/androidUnitTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/JournalHarness.android.kt @@ -0,0 +1,6 @@ +package org.mobilenativefoundation.store6.mutations.sqldelight + +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver + +internal actual fun freshJournalHarness(): JournalHarness = + JournalHarness(JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)) diff --git a/store6-mutations-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/SqlDelightMutationJournalStorage.kt b/store6-mutations-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/SqlDelightMutationJournalStorage.kt new file mode 100644 index 000000000..b2f7f1b68 --- /dev/null +++ b/store6-mutations-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/SqlDelightMutationJournalStorage.kt @@ -0,0 +1,1653 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, +) + +package org.mobilenativefoundation.store6.mutations.sqldelight + +import app.cash.sqldelight.Transacter +import app.cash.sqldelight.db.QueryResult +import app.cash.sqldelight.db.SqlCursor +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.db.SqlPreparedStatement +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.mutations.MutationFailureKind +import org.mobilenativefoundation.store6.mutations.MutationPresenceState +import org.mobilenativefoundation.store6.mutations.sqldelight.internal.MutationJournalSidecar +import org.mobilenativefoundation.store6.mutations.storage.MutationAckRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationAliasState +import org.mobilenativefoundation.store6.mutations.storage.MutationAttemptRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationClientRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationEffectDisposition +import org.mobilenativefoundation.store6.mutations.storage.MutationEffectKind +import org.mobilenativefoundation.store6.mutations.storage.MutationEffectRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionPhase +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationFailureRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationIntentRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationJournalStorage +import org.mobilenativefoundation.store6.mutations.storage.MutationJournalTransaction +import org.mobilenativefoundation.store6.mutations.storage.MutationKeyAliasRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationKeyTombstoneRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationTombstoneState + +/** + * SQLite-backed mutation journal. + * + * [driver] and [transacter] must address the same connection/database authority. Store6 cannot + * verify that pairing; violating it breaks transaction and storage-local ID guarantees. The + * synchronous transaction boundary supports drivers whose raw operations return + * [QueryResult.Value]; async web drivers are not supported by this adapter. Construct this + * adapter before exposing the driver concurrently, and either dedicate the driver to mutation + * journal adapters or externally serialize all other driver access. The adapter cannot coordinate + * arbitrary SQL or adapter families outside this module. + */ +@ExperimentalStoreApi +@OptIn(DelicateStoreApi::class) +public class SqlDelightMutationJournalStorage( + private val driver: SqlDriver, + private val transacter: Transacter, +) : MutationJournalStorage { + @Suppress("unused") + private val sidecar: MutationJournalSidecar = MutationJournalSidecar(driver, transacter) + private val gate: Mutex = JournalDriverGates.forDriver(driver) + + public override suspend fun transaction( + block: (MutationJournalTransaction) -> R, + ): R = + gate.withLock { + transacter.transactionWithResult { + val transaction = SqlDelightMutationJournalTransaction(driver) + try { + val result = block(transaction) + transaction.validateFinalState() + result + } finally { + transaction.close() + } + } + } +} + +private object JournalDriverGates { + private val gates: Array = Array(64) { Mutex() } + + fun forDriver(driver: SqlDriver): Mutex = gates[driver.hashCode() and (gates.size - 1)] +} + +private class SqlDelightMutationJournalTransaction( + private val driver: SqlDriver, +) : MutationJournalTransaction { + private var active: Boolean = true + private val clientTransitions: MutableList = mutableListOf() + private val executionTransitions: MutableList = mutableListOf() + private val aliasTransitions: MutableList = mutableListOf() + private val tombstoneTransitions: MutableList = mutableListOf() + + fun close() { + active = false + } + + override fun client(clientId: String): MutationClientRecord? { + requireActive() + return queryOne( + """SELECT record_version, client_id, last_allocated_sequence, + retired_through_sequence, server_confirmed_retired_through_sequence, + created_at + FROM store6_mutation_client WHERE client_id = ?""", + parameters = 1, + binders = { bindString(0, clientId) }, + ) { cursor -> + MutationClientRecord( + recordVersion = cursor.requiredInt(0, "client record version"), + clientId = cursor.requiredString(1), + lastAllocatedSequence = cursor.requiredLong(2), + retiredThroughSequence = cursor.requiredLong(3), + serverConfirmedRetiredThroughSequence = cursor.requiredLong(4), + createdAt = cursor.requiredLong(5), + ) + } + } + + override fun intents(clientId: String): List { + requireActive() + return queryList( + """SELECT row_id, record_version, client_id, client_sequence, mutation_id, + namespace, canonical_id, mutator_id, mutator_version, args_blob, + idempotency_root, created_at + FROM store6_mutation_intent WHERE client_id = ? ORDER BY client_sequence""", + parameters = 1, + binders = { bindString(0, clientId) }, + ) { cursor -> cursor.toIntent() } + } + + override fun executions(clientId: String): List { + requireActive() + return queryList( + """SELECT client_id, client_sequence, phase, current_generation, attempt, + last_attempt_at, active_failure_id, retired_at + FROM store6_mutation_execution WHERE client_id = ? ORDER BY client_sequence""", + parameters = 1, + binders = { bindString(0, clientId) }, + ) { cursor -> + MutationExecutionRecord( + clientId = cursor.requiredString(0), + clientSequence = cursor.requiredLong(1), + phase = enumNamed(cursor.requiredString(2), MutationExecutionPhase.entries, "execution phase"), + currentGeneration = cursor.requiredInt(3, "execution generation"), + attempt = cursor.requiredInt(4, "execution attempt"), + lastAttemptAt = cursor.getLong(5), + activeFailureId = cursor.getLong(6), + retiredAt = cursor.getLong(7), + ) + } + } + + override fun attempts(clientId: String): List { + requireActive() + return queryList( + """SELECT client_id, client_sequence, generation, effective_namespace, + effective_canonical_id, value_codec_version, base_presence, base_blob, + mine_presence, mine_blob, precondition_meta_present, + precondition_written_at, precondition_etag, + advertised_retired_through_sequence, generation_idempotency_key, + prepared_at, conflict_meta_present, conflict_written_at, conflict_etag, + conflict_received_at + FROM store6_mutation_attempt WHERE client_id = ? + ORDER BY client_sequence, generation""", + parameters = 1, + binders = { bindString(0, clientId) }, + ) { cursor -> cursor.toAttempt() } + } + + override fun acks(clientId: String): List { + requireActive() + return queryList( + """SELECT client_id, client_sequence, generation, authoritative_presence, + authoritative_blob, value_codec_version, etag, + canonical_target_namespace, canonical_target_id, received_at + FROM store6_mutation_ack WHERE client_id = ? + ORDER BY client_sequence, generation""", + parameters = 1, + binders = { bindString(0, clientId) }, + ) { cursor -> cursor.toAck() } + } + + override fun failures(clientId: String): List { + requireActive() + return queryList( + """SELECT failure_id, client_id, client_sequence, generation, kind, detail, + message, occurred_at + FROM store6_mutation_failure WHERE client_id = ? ORDER BY failure_id""", + parameters = 1, + binders = { bindString(0, clientId) }, + ) { cursor -> cursor.toFailure() } + } + + override fun effects(clientId: String): List { + requireActive() + return queryList( + """SELECT client_id, client_sequence, effect_index, kind, namespace, canonical_id, + created_at, disposition, completed_at + FROM store6_mutation_effect WHERE client_id = ? + ORDER BY client_sequence, effect_index""", + parameters = 1, + binders = { bindString(0, clientId) }, + ) { cursor -> cursor.toEffect() } + } + + override fun aliases(): List { + requireActive() + return queryList( + """SELECT source_namespace, source_canonical_id, target_namespace, + target_canonical_id, state, created_by_client_id, created_by_sequence, + created_at, activated_at + FROM store6_key_alias ORDER BY source_namespace, source_canonical_id""", + ) { cursor -> cursor.toAlias() } + } + + override fun tombstones(): List { + requireActive() + return queryList( + """SELECT namespace, canonical_id, created_by_client_id, created_by_sequence, + state, created_at, activated_at, superseded_by_client_id, + superseded_by_sequence, superseded_at + FROM store6_key_tombstone + ORDER BY namespace, canonical_id, created_by_client_id, created_by_sequence""", + ) { cursor -> cursor.toTombstone() } + } + + override fun insertClient(record: MutationClientRecord) { + requireActive() + require(client(record.clientId) == null) { "client already exists: ${record.clientId}" } + require( + record.recordVersion == 1 && + record.lastAllocatedSequence == 0L && + record.retiredThroughSequence == 0L && + record.serverConfirmedRetiredThroughSequence == 0L, + ) { "a new client must start at version 1 with zero allocation and retirement prefixes" } + execute( + """INSERT INTO store6_mutation_client( + record_version, client_id, last_allocated_sequence, retired_through_sequence, + server_confirmed_retired_through_sequence, created_at) + VALUES (?, ?, ?, ?, ?, ?)""", + 6, + ) { + bindLong(0, record.recordVersion.toLong()) + bindString(1, record.clientId) + bindLong(2, record.lastAllocatedSequence) + bindLong(3, record.retiredThroughSequence) + bindLong(4, record.serverConfirmedRetiredThroughSequence) + bindLong(5, record.createdAt) + }.requireOneRow("client insert") + } + + override fun advanceClient(record: MutationClientRecord) { + requireActive() + val previous = requireNotNull(client(record.clientId)) { + "client does not exist: ${record.clientId}" + } + require(record.recordVersion == previous.recordVersion) { "recordVersion is immutable" } + require(record.createdAt == previous.createdAt) { "createdAt is immutable" } + require(record.lastAllocatedSequence >= previous.lastAllocatedSequence) { + "lastAllocatedSequence cannot regress" + } + require(record.retiredThroughSequence >= previous.retiredThroughSequence) { + "retiredThroughSequence cannot regress" + } + require( + record.serverConfirmedRetiredThroughSequence == + previous.serverConfirmedRetiredThroughSequence, + ) { "checkpoint confirmation advances only through confirmRetiredThrough" } + execute( + """UPDATE store6_mutation_client + SET last_allocated_sequence = ?, retired_through_sequence = ? + WHERE client_id = ?""", + 3, + ) { + bindLong(0, record.lastAllocatedSequence) + bindLong(1, record.retiredThroughSequence) + bindString(2, record.clientId) + }.requireOneRow("client advance") + clientTransitions += ClientTransition(previous, record) + } + + override fun confirmRetiredThrough( + clientId: String, + requestedThroughSequence: Long, + serverConfirmedThroughSequence: Long, + ): MutationClientRecord { + requireActive() + val previous = requireNotNull(client(clientId)) { "client does not exist: $clientId" } + require(requestedThroughSequence >= 0L) { "requested checkpoint must be non-negative" } + require(requestedThroughSequence <= previous.retiredThroughSequence) { + "requested checkpoint cannot exceed the local retirement prefix" + } + require(serverConfirmedThroughSequence <= requestedThroughSequence) { + "server confirmation cannot exceed the exact checkpoint request" + } + require( + serverConfirmedThroughSequence >= previous.serverConfirmedRetiredThroughSequence, + ) { "server confirmation cannot regress" } + execute( + """UPDATE store6_mutation_client + SET server_confirmed_retired_through_sequence = ? WHERE client_id = ?""", + 2, + ) { + bindLong(0, serverConfirmedThroughSequence) + bindString(1, clientId) + }.requireOneRow("checkpoint confirmation") + return MutationClientRecord( + recordVersion = previous.recordVersion, + clientId = previous.clientId, + lastAllocatedSequence = previous.lastAllocatedSequence, + retiredThroughSequence = previous.retiredThroughSequence, + serverConfirmedRetiredThroughSequence = serverConfirmedThroughSequence, + createdAt = previous.createdAt, + ) + } + + override fun insertIntent( + recordVersion: Int, + clientId: String, + clientSequence: Long, + mutationId: String, + namespace: String, + canonicalId: String, + mutatorId: String, + mutatorVersion: Int, + argsBlob: ByteArray, + idempotencyRoot: String, + createdAt: Long, + ): MutationIntentRecord { + requireActive() + val accepted = MutationIntentRecord( + rowId = 0L, + recordVersion = recordVersion, + clientId = clientId, + clientSequence = clientSequence, + mutationId = mutationId, + namespace = namespace, + canonicalId = canonicalId, + mutatorId = mutatorId, + mutatorVersion = mutatorVersion, + argsBlob = argsBlob, + idempotencyRoot = idempotencyRoot, + createdAt = createdAt, + ) + val owner = requireNotNull(client(accepted.clientId)) { + "client does not exist: ${accepted.clientId}" + } + require(accepted.clientSequence > owner.retiredThroughSequence) { + "intent sequence must be newer than the retired client prefix" + } + require(accepted.clientSequence <= owner.lastAllocatedSequence) { + "intent sequence exceeds the allocated client sequence" + } + val existing = intents(accepted.clientId) + require(existing.none { it.clientSequence == accepted.clientSequence }) { + "intent sequence already exists: ${accepted.clientSequence}" + } + require(existing.none { it.mutationId == accepted.mutationId }) { + "mutationId already exists for client: ${accepted.mutationId}" + } + require( + !exists( + "SELECT 1 FROM store6_mutation_intent WHERE idempotency_root = ?", + 1, + ) { bindString(0, accepted.idempotencyRoot) }, + ) { "idempotencyRoot already exists: ${accepted.idempotencyRoot}" } + execute( + """INSERT INTO store6_mutation_intent( + record_version, client_id, client_sequence, mutation_id, namespace, canonical_id, + mutator_id, mutator_version, args_blob, idempotency_root, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + 11, + ) { + bindLong(0, accepted.recordVersion.toLong()) + bindString(1, accepted.clientId) + bindLong(2, accepted.clientSequence) + bindString(3, accepted.mutationId) + bindString(4, accepted.namespace) + bindString(5, accepted.canonicalId) + bindString(6, accepted.mutatorId) + bindLong(7, accepted.mutatorVersion.toLong()) + bindBytes(8, accepted.argsBlob) + bindString(9, accepted.idempotencyRoot) + bindLong(10, accepted.createdAt) + }.requireOneRow("intent insert") + val rowId = requireNotNull(queryLong("SELECT last_insert_rowid()")) + return MutationIntentRecord( + rowId = rowId, + recordVersion = accepted.recordVersion, + clientId = accepted.clientId, + clientSequence = accepted.clientSequence, + mutationId = accepted.mutationId, + namespace = accepted.namespace, + canonicalId = accepted.canonicalId, + mutatorId = accepted.mutatorId, + mutatorVersion = accepted.mutatorVersion, + argsBlob = accepted.argsBlob, + idempotencyRoot = accepted.idempotencyRoot, + createdAt = accepted.createdAt, + ) + } + + override fun insertExecution(record: MutationExecutionRecord) { + requireActive() + require(intents(record.clientId).any { it.clientSequence == record.clientSequence }) { + "execution requires an intent" + } + require(executions(record.clientId).none { it.clientSequence == record.clientSequence }) { + "execution already exists" + } + require(record.phase == MutationExecutionPhase.UNPREPARED) { + "new execution must start UNPREPARED" + } + execute( + """INSERT INTO store6_mutation_execution( + client_id, client_sequence, phase, current_generation, attempt, last_attempt_at, + active_failure_id, retired_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + 8, + ) { + bindString(0, record.clientId) + bindLong(1, record.clientSequence) + bindString(2, record.phase.name) + bindLong(3, record.currentGeneration.toLong()) + bindLong(4, record.attempt.toLong()) + bindLong(5, record.lastAttemptAt) + bindLong(6, record.activeFailureId) + bindLong(7, record.retiredAt) + }.requireOneRow("execution insert") + } + + override fun advanceExecution(record: MutationExecutionRecord) { + requireActive() + val previous = + requireNotNull(executions(record.clientId).firstOrNull { + it.clientSequence == record.clientSequence + }) { "execution does not exist" } + if (previous.sameValueAs(record)) return + require(record.currentGeneration >= previous.currentGeneration) { + "currentGeneration cannot regress" + } + val activeFailureKind = + if (record.phase == MutationExecutionPhase.PARKED) { + val activeFailureId = requireNotNull(record.activeFailureId) + val failure = + failures(record.clientId).firstOrNull { it.failureId == activeFailureId } + requireNotNull(failure) { "PARKED requires its active failure row" } + require( + failure.clientId == record.clientId && + failure.clientSequence == record.clientSequence && + failure.generation == record.currentGeneration, + ) { "active failure identity does not match the execution" } + failure.kind + } else { + null + } + requireExecutionTransition(previous, record, activeFailureKind) + if (record.currentGeneration > 0) { + require(attempts(record.clientId).any { + it.clientSequence == record.clientSequence && + it.generation == record.currentGeneration + }) { "execution generation requires an immutable attempt row" } + } + val generationContinuation = + previous.phase == MutationExecutionPhase.REFRESH_REQUIRED && + record.phase == MutationExecutionPhase.READY && + record.currentGeneration == previous.currentGeneration + 1 + if (generationContinuation) { + val durableAttempts = attempts(record.clientId) + val previousAttempt = + requireNotNull( + durableAttempts.singleOrNull { + it.clientSequence == record.clientSequence && + it.generation == previous.currentGeneration + }, + ) { "generation continuation requires the previous immutable attempt" } + val nextAttempt = + requireNotNull( + durableAttempts.singleOrNull { + it.clientSequence == record.clientSequence && + it.generation == record.currentGeneration + }, + ) { "generation continuation requires the next immutable attempt" } + require(isUniqueNamespaceOwner(previous)) { + "generation continuation requires the previous unique namespace owner" + } + requireNamespaceAuthorityAvailable(record) + require( + previousAttempt.effectiveNamespace == nextAttempt.effectiveNamespace && + previousAttempt.effectiveCanonicalId == nextAttempt.effectiveCanonicalId, + ) { "generation continuation must preserve exact effective identity" } + } + if (record.phase == MutationExecutionPhase.ACKED) { + require(acks(record.clientId).any { + it.clientSequence == record.clientSequence && + it.generation == record.currentGeneration + }) { "ACKED requires its matching acknowledgement" } + } + if (record.phase == MutationExecutionPhase.RETIRED) { + require(effects(record.clientId).none { + it.clientSequence == record.clientSequence && + it.disposition == MutationEffectDisposition.PENDING + }) { "an execution cannot retire with a pending effect" } + } + if ( + previous.phase == MutationExecutionPhase.READY && + record.phase == MutationExecutionPhase.INFLIGHT + ) { + requireNamespaceAuthorityAvailable(record) + } + val wasUniqueNamespaceOwner = + previous.phase == MutationExecutionPhase.EFFECTS_PENDING && + record.phase == MutationExecutionPhase.RETIRED && + isUniqueNamespaceOwner(previous) + execute( + """UPDATE store6_mutation_execution + SET phase = ?, current_generation = ?, attempt = ?, last_attempt_at = ?, + active_failure_id = ?, retired_at = ? + WHERE client_id = ? AND client_sequence = ?""", + 8, + ) { + bindString(0, record.phase.name) + bindLong(1, record.currentGeneration.toLong()) + bindLong(2, record.attempt.toLong()) + bindLong(3, record.lastAttemptAt) + bindLong(4, record.activeFailureId) + bindLong(5, record.retiredAt) + bindString(6, record.clientId) + bindLong(7, record.clientSequence) + }.requireOneRow("execution advance") + executionTransitions += + ExecutionTransition( + previous = previous, + next = record, + wasUniqueNamespaceOwner = wasUniqueNamespaceOwner, + ) + } + + override fun insertAttempt(record: MutationAttemptRecord) { + requireActive() + require(intents(record.clientId).any { it.clientSequence == record.clientSequence }) { + "attempt requires an intent" + } + require(attempts(record.clientId).none { + it.clientSequence == record.clientSequence && it.generation == record.generation + }) { "attempt generation already exists" } + require( + !exists( + "SELECT 1 FROM store6_mutation_attempt WHERE generation_idempotency_key = ?", + 1, + ) { bindString(0, record.generationIdempotencyKey) }, + ) { "generationIdempotencyKey already exists" } + require(record.conflictMetaPresent == null) { + "a new attempt cannot contain a conflict receipt" + } + execute( + """INSERT INTO store6_mutation_attempt( + client_id, client_sequence, generation, effective_namespace, + effective_canonical_id, value_codec_version, base_presence, base_blob, + mine_presence, mine_blob, precondition_meta_present, precondition_written_at, + precondition_etag, advertised_retired_through_sequence, + generation_idempotency_key, prepared_at, conflict_meta_present, + conflict_written_at, conflict_etag, conflict_received_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + 20, + ) { + bindAttempt(record) + }.requireOneRow("attempt insert") + } + + override fun recordConflictReceipt(record: MutationAttemptRecord) { + requireActive() + val previous = + requireNotNull(attempts(record.clientId).firstOrNull { + it.clientSequence == record.clientSequence && it.generation == record.generation + }) { "attempt does not exist" } + require(previous.samePreparationAs(record)) { "attempt preparation fields are immutable" } + require(record.conflictMetaPresent != null && record.conflictReceivedAt != null) { + "conflict receipt is incomplete" + } + if (previous.sameValueAs(record)) return + require(previous.conflictMetaPresent == null) { "conflict receipt is write-once" } + execute( + """UPDATE store6_mutation_attempt + SET conflict_meta_present = ?, conflict_written_at = ?, conflict_etag = ?, + conflict_received_at = ? + WHERE client_id = ? AND client_sequence = ? AND generation = ?""", + 7, + ) { + bindBoolean(0, record.conflictMetaPresent) + bindLong(1, record.conflictWrittenAt) + bindString(2, record.conflictEtag) + bindLong(3, record.conflictReceivedAt) + bindString(4, record.clientId) + bindLong(5, record.clientSequence) + bindLong(6, record.generation.toLong()) + }.requireOneRow("conflict receipt") + } + + override fun insertAck(record: MutationAckRecord) { + requireActive() + val attempt = + requireNotNull(attempts(record.clientId).firstOrNull { + it.clientSequence == record.clientSequence && it.generation == record.generation + }) { "acknowledgement requires its attempt generation" } + if (record.canonicalTargetNamespace != null) { + require(record.canonicalTargetNamespace == attempt.effectiveNamespace) { + "canonical acknowledgement target cannot cross namespaces" + } + } + val previous = acks(record.clientId).firstOrNull { + it.clientSequence == record.clientSequence && it.generation == record.generation + } + if (previous != null) { + require(previous.sameValueAs(record)) { "acknowledgement is write-once" } + return + } + execute( + """INSERT INTO store6_mutation_ack( + client_id, client_sequence, generation, authoritative_presence, + authoritative_blob, value_codec_version, etag, canonical_target_namespace, + canonical_target_id, received_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + 10, + ) { + bindString(0, record.clientId) + bindLong(1, record.clientSequence) + bindLong(2, record.generation.toLong()) + bindString(3, record.authoritativePresence.name) + bindBytes(4, record.authoritativeBlob?.copyOf()) + bindLong(5, record.valueCodecVersion.toLong()) + bindString(6, record.etag) + bindString(7, record.canonicalTargetNamespace) + bindString(8, record.canonicalTargetId) + bindLong(9, record.receivedAt) + }.requireOneRow("acknowledgement insert") + } + + override fun appendFailure( + clientId: String, + clientSequence: Long, + generation: Int, + kind: MutationFailureKind, + detail: String, + message: String, + occurredAt: Long, + ): MutationFailureRecord { + requireActive() + require(intents(clientId).any { it.clientSequence == clientSequence }) { + "failure requires an intent" + } + val boundedDetail = detail.truncateUtf8(128) + val boundedMessage = message.truncateUtf8(1_024) + MutationFailureRecord( + 0L, clientId, clientSequence, generation, kind, boundedDetail, boundedMessage, occurredAt, + ) + execute( + """INSERT INTO store6_mutation_failure( + client_id, client_sequence, generation, kind, detail, message, occurred_at) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + 7, + ) { + bindString(0, clientId) + bindLong(1, clientSequence) + bindLong(2, generation.toLong()) + bindString(3, kind.name) + bindString(4, boundedDetail) + bindString(5, boundedMessage) + bindLong(6, occurredAt) + }.requireOneRow("failure insert") + return MutationFailureRecord( + failureId = requireNotNull(queryLong("SELECT last_insert_rowid()")), + clientId = clientId, + clientSequence = clientSequence, + generation = generation, + kind = kind, + detail = boundedDetail, + message = boundedMessage, + occurredAt = occurredAt, + ) + } + + override fun insertEffect(record: MutationEffectRecord) { + requireActive() + require(intents(record.clientId).any { it.clientSequence == record.clientSequence }) { + "effect requires an intent" + } + require(record.disposition == MutationEffectDisposition.PENDING) { + "new effect must start PENDING" + } + require(effects(record.clientId).none { + it.clientSequence == record.clientSequence && it.effectIndex == record.effectIndex + }) { "effect identity already exists" } + execute( + """INSERT INTO store6_mutation_effect( + client_id, client_sequence, effect_index, kind, namespace, canonical_id, + created_at, disposition, completed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", + 9, + ) { + bindString(0, record.clientId) + bindLong(1, record.clientSequence) + bindLong(2, record.effectIndex.toLong()) + bindString(3, record.kind.name) + bindString(4, record.namespace) + bindString(5, record.canonicalId) + bindLong(6, record.createdAt) + bindString(7, record.disposition.name) + bindLong(8, record.completedAt) + }.requireOneRow("effect insert") + } + + override fun advanceEffect(record: MutationEffectRecord) { + requireActive() + val previous = + requireNotNull(effects(record.clientId).firstOrNull { + it.clientSequence == record.clientSequence && it.effectIndex == record.effectIndex + }) { "effect does not exist" } + if (previous.sameValueAs(record)) return + require(previous.sameIdentityAndTargetAs(record)) { "effect identity and target are immutable" } + require(previous.disposition == MutationEffectDisposition.PENDING) { + "terminal effect disposition cannot change" + } + require(record.disposition != MutationEffectDisposition.PENDING) { + "effect disposition cannot regress to PENDING" + } + execute( + """UPDATE store6_mutation_effect SET disposition = ?, completed_at = ? + WHERE client_id = ? AND client_sequence = ? AND effect_index = ?""", + 5, + ) { + bindString(0, record.disposition.name) + bindLong(1, record.completedAt) + bindString(2, record.clientId) + bindLong(3, record.clientSequence) + bindLong(4, record.effectIndex.toLong()) + }.requireOneRow("effect advance") + } + + override fun insertAlias(record: MutationKeyAliasRecord) { + requireActive() + require(record.state == MutationAliasState.PENDING) { "new alias must start PENDING" } + val previous = aliases().firstOrNull { + it.sourceNamespace == record.sourceNamespace && + it.sourceCanonicalId == record.sourceCanonicalId + } + if (previous != null) { + require(previous.sameRouteAs(record)) { "alias source cannot be retargeted" } + return + } + require(!wouldCreateAliasCycle(record)) { "alias edge would create a cycle" } + execute( + """INSERT INTO store6_key_alias( + source_namespace, source_canonical_id, target_namespace, target_canonical_id, + state, created_by_client_id, created_by_sequence, created_at, activated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", + 9, + ) { + bindString(0, record.sourceNamespace) + bindString(1, record.sourceCanonicalId) + bindString(2, record.targetNamespace) + bindString(3, record.targetCanonicalId) + bindString(4, record.state.name) + bindString(5, record.createdByClientId) + bindLong(6, record.createdBySequence) + bindLong(7, record.createdAt) + bindLong(8, record.activatedAt) + }.requireOneRow("alias insert") + } + + override fun advanceAlias(record: MutationKeyAliasRecord) { + requireActive() + val previous = + requireNotNull(aliases().firstOrNull { + it.sourceNamespace == record.sourceNamespace && + it.sourceCanonicalId == record.sourceCanonicalId + }) { "alias does not exist" } + if (previous.sameValueAs(record)) return + require(previous.sameEdgeAs(record)) { "alias edge and creator are immutable" } + require( + previous.state == MutationAliasState.PENDING && record.state == MutationAliasState.ACTIVE, + ) { "alias state can only advance PENDING to ACTIVE" } + execute( + """UPDATE store6_key_alias SET state = ?, activated_at = ? + WHERE source_namespace = ? AND source_canonical_id = ?""", + 4, + ) { + bindString(0, record.state.name) + bindLong(1, record.activatedAt) + bindString(2, record.sourceNamespace) + bindString(3, record.sourceCanonicalId) + }.requireOneRow("alias advance") + aliasTransitions += AliasTransition(previous, record) + } + + override fun insertTombstone(record: MutationKeyTombstoneRecord) { + requireActive() + require(record.state == MutationTombstoneState.PENDING) { + "new tombstone must start PENDING" + } + val previous = tombstones().firstOrNull { it.sameKeyAs(record) } + if (previous != null) { + require(previous.sameValueAs(record)) { "tombstone generation is immutable" } + return + } + requireNoOtherTombstoneInState(record, MutationTombstoneState.PENDING, excluding = null) + insertTombstoneRow(record) + } + + override fun advanceTombstone(record: MutationKeyTombstoneRecord) { + requireActive() + val previous = requireNotNull(tombstones().firstOrNull { it.sameKeyAs(record) }) { + "tombstone generation does not exist" + } + if (previous.sameValueAs(record)) return + require(previous.sameGenerationAs(record)) { "tombstone generation identity is immutable" } + val legal = + (previous.state == MutationTombstoneState.PENDING && + record.state == MutationTombstoneState.ACTIVE) || + (previous.state == MutationTombstoneState.ACTIVE && + record.state == MutationTombstoneState.SUPERSEDED) + require(legal) { "tombstone state must advance PENDING to ACTIVE to SUPERSEDED" } + if ( + previous.state == MutationTombstoneState.ACTIVE && + record.state == MutationTombstoneState.SUPERSEDED + ) { + require(record.activatedAt == previous.activatedAt) { + "tombstone activation timestamp is immutable after activation" + } + if (record.supersededByClientId == previous.createdByClientId) { + require(record.supersededBySequence != previous.createdBySequence) { + "a same-client tombstone successor must be causally distinct" + } + } + } + requireNoOtherTombstoneInState(record, record.state, excluding = record) + execute( + """UPDATE store6_key_tombstone + SET state = ?, activated_at = ?, superseded_by_client_id = ?, + superseded_by_sequence = ?, superseded_at = ? + WHERE namespace = ? AND canonical_id = ? AND created_by_client_id = ? + AND created_by_sequence = ?""", + 9, + ) { + bindString(0, record.state.name) + bindLong(1, record.activatedAt) + bindString(2, record.supersededByClientId) + bindLong(3, record.supersededBySequence) + bindLong(4, record.supersededAt) + bindString(5, record.namespace) + bindString(6, record.canonicalId) + bindString(7, record.createdByClientId) + bindLong(8, record.createdBySequence) + }.requireOneRow("tombstone advance") + tombstoneTransitions += TombstoneTransition(previous, record) + } + + override fun prune( + clientId: String, + serverConfirmedRetiredThroughSequence: Long, + ) { + requireActive() + val owner = requireNotNull(client(clientId)) { "client does not exist: $clientId" } + require(serverConfirmedRetiredThroughSequence >= 0L) { "prune prefix must be non-negative" } + require( + serverConfirmedRetiredThroughSequence <= + owner.serverConfirmedRetiredThroughSequence, + ) { "prune prefix exceeds the persisted server-confirmed prefix" } + + val removableTombstones = + tombstones().filter { tombstone -> + tombstone.createdByClientId == clientId && + tombstone.createdBySequence <= serverConfirmedRetiredThroughSequence && + tombstone.state == MutationTombstoneState.SUPERSEDED && + tombstone.supersedingIntentIsConfirmed() + } + listOf( + "store6_mutation_effect", + "store6_mutation_ack", + "store6_mutation_failure", + "store6_mutation_attempt", + "store6_mutation_execution", + "store6_mutation_intent", + ).forEach { table -> + execute( + "DELETE FROM $table WHERE client_id = ? AND client_sequence <= ?", + 2, + ) { + bindString(0, clientId) + bindLong(1, serverConfirmedRetiredThroughSequence) + } + } + removableTombstones.forEach { tombstone -> + execute( + """DELETE FROM store6_key_tombstone + WHERE namespace = ? AND canonical_id = ? AND created_by_client_id = ? + AND created_by_sequence = ?""", + 4, + ) { + bindString(0, tombstone.namespace) + bindString(1, tombstone.canonicalId) + bindString(2, tombstone.createdByClientId) + bindLong(3, tombstone.createdBySequence) + }.requireOneRow("tombstone prune") + } + } + + fun validateFinalState() { + requireActive() + val lowerSuccessors = + tombstoneTransitions.filter { transition -> transition.isSameClientLowerSuccessor() } + if (lowerSuccessors.isEmpty()) return + + lowerSuccessors + .groupBy { transition -> + SuccessorKey( + clientId = requireNotNull(transition.next.supersededByClientId), + sequence = requireNotNull(transition.next.supersededBySequence), + ) + }.forEach { (successor, predecessors) -> + validateCausalLowerSuccessor(successor, predecessors) + } + } + + private fun validateCausalLowerSuccessor( + successor: SuccessorKey, + predecessors: List, + ) { + require( + predecessors.all { transition -> + transition.previous.state == MutationTombstoneState.ACTIVE && + transition.next.state == MutationTombstoneState.SUPERSEDED && + transition.previous.activatedAt == transition.next.activatedAt && + transition.next.supersededByClientId == successor.clientId && + transition.next.supersededBySequence == successor.sequence + }, + ) { "a lower causal successor must preserve every ACTIVE predecessor exactly" } + + val retirement = + executionTransitions.singleOrNull { transition -> + transition.previous.clientId == successor.clientId && + transition.previous.clientSequence == successor.sequence && + transition.previous.phase == MutationExecutionPhase.EFFECTS_PENDING && + transition.next.phase == MutationExecutionPhase.RETIRED + } + require(retirement != null && retirement.wasUniqueNamespaceOwner) { + "a lower causal successor must retire from unique EFFECTS_PENDING authority" + } + + val finalExecutions = executions(successor.clientId) + val successorExecution = + finalExecutions.firstOrNull { execution -> + execution.clientSequence == successor.sequence + } + require( + successorExecution != null && + successorExecution.phase == MutationExecutionPhase.RETIRED && + successorExecution.currentGeneration == retirement.previous.currentGeneration, + ) { "a lower causal successor must commit its exact RETIRED execution" } + + val attemptKey = + AttemptKey( + clientId = successor.clientId, + sequence = successor.sequence, + generation = retirement.previous.currentGeneration, + ) + val finalAttempts = attempts(successor.clientId).associateBy { attempt -> attempt.key() } + val attempt = requireNotNull(finalAttempts[attemptKey]) { + "a lower causal successor requires its exact attempt" + } + val acknowledgement = + requireNotNull( + acks(successor.clientId).firstOrNull { acknowledgement -> + acknowledgement.clientSequence == successor.sequence && + acknowledgement.generation == attempt.generation + }, + ) { "a lower causal successor requires its exact acknowledgement" } + require( + acknowledgement.authoritativePresence == MutationPresenceState.PRESENT && + acknowledgement.canonicalTargetNamespace != null && + acknowledgement.canonicalTargetId != null, + ) { "a lower causal successor requires a PRESENT canonical acknowledgement" } + require( + effects(successor.clientId).none { effect -> + effect.clientSequence == successor.sequence && + effect.disposition == MutationEffectDisposition.PENDING + }, + ) { "a lower causal successor cannot commit with a pending effect" } + + val aliasTransition = + aliasTransitions.singleOrNull { transition -> + transition.previous.state == MutationAliasState.PENDING && + transition.next.state == MutationAliasState.ACTIVE && + transition.next.createdByClientId == successor.clientId && + transition.next.createdBySequence == successor.sequence && + transition.next.sourceNamespace == attempt.effectiveNamespace && + transition.next.sourceCanonicalId == attempt.effectiveCanonicalId && + transition.next.targetNamespace == acknowledgement.canonicalTargetNamespace && + transition.next.targetCanonicalId == acknowledgement.canonicalTargetId + } + require(aliasTransition != null) { + "a lower causal successor must activate its exact acknowledged PENDING alias" + } + val finalAliases = aliases() + val finalAlias = + finalAliases.firstOrNull { alias -> + alias.sourceNamespace == aliasTransition.next.sourceNamespace && + alias.sourceCanonicalId == aliasTransition.next.sourceCanonicalId + } + require(finalAlias != null && finalAlias.sameValueAs(aliasTransition.next)) { + "a lower causal successor must commit its activated alias" + } + + val route = + activeRouteFrom( + source = IdentityKey(attempt.effectiveNamespace, attempt.effectiveCanonicalId), + aliases = finalAliases, + ) + require( + predecessors.all { transition -> + IdentityKey(transition.previous.namespace, transition.previous.canonicalId) in route + }, + ) { "a lower causal successor route must contain every predecessor" } + + val finalTombstones = tombstones() + val collapsedTransitions = + tombstoneTransitions.filter { transition -> + transition.previous.state == MutationTombstoneState.ACTIVE && + IdentityKey( + transition.previous.namespace, + transition.previous.canonicalId, + ) in route + } + require( + collapsedTransitions.all { transition -> + transition.next.state == MutationTombstoneState.SUPERSEDED && + transition.next.supersededByClientId == successor.clientId && + transition.next.supersededBySequence == successor.sequence && + transition.previous.activatedAt == transition.next.activatedAt && + finalTombstones.firstOrNull { it.sameKeyAs(transition.next) } + ?.sameValueAs(transition.next) == true + }, + ) { "every collapsed ACTIVE tombstone must name the exact causal successor" } + require( + finalTombstones.none { tombstone -> + tombstone.state == MutationTombstoneState.ACTIVE && + IdentityKey(tombstone.namespace, tombstone.canonicalId) in route + }, + ) { "a lower causal successor must collapse every ACTIVE tombstone in its route" } + + requireTruthfulPrefixAdvance(successor.clientId, finalExecutions) + require( + finalExecutions.none { execution -> + execution.ownsNamespaceAuthority() && + namespaceFor(execution, finalAttempts) == attempt.effectiveNamespace + }, + ) { "a lower causal successor must release namespace authority in the same commit" } + } + + private fun requireTruthfulPrefixAdvance( + clientId: String, + finalExecutions: List, + ) { + val prefixTransitions = + clientTransitions.filter { transition -> + transition.previous.clientId == clientId && + transition.next.retiredThroughSequence > + transition.previous.retiredThroughSequence + } + require(prefixTransitions.isNotEmpty()) { + "a lower causal successor must advance the client retirement prefix" + } + val startingPrefix = prefixTransitions.first().previous.retiredThroughSequence + val finalClient = requireNotNull(client(clientId)) + val executionsBySequence = finalExecutions.associateBy { it.clientSequence } + var truthfulPrefix = startingPrefix + while (truthfulPrefix < finalClient.lastAllocatedSequence) { + val next = executionsBySequence[truthfulPrefix + 1L] ?: break + if (next.phase != MutationExecutionPhase.RETIRED) break + truthfulPrefix += 1L + } + require( + finalClient.retiredThroughSequence > startingPrefix && + finalClient.retiredThroughSequence == truthfulPrefix, + ) { "a lower causal successor requires an exact truthful retirement-prefix advance" } + } + + private fun requireNamespaceAuthorityAvailable(candidate: MutationExecutionRecord) { + val allAttempts = attempts(candidate.clientId).associateBy { attempt -> attempt.key() } + val namespace = namespaceFor(candidate, allAttempts) + require( + executions(candidate.clientId).none { execution -> + execution.clientSequence != candidate.clientSequence && + execution.ownsNamespaceAuthority() && + namespaceFor(execution, allAttempts) == namespace + }, + ) { "namespace authority already has an owner" } + } + + private fun isUniqueNamespaceOwner(candidate: MutationExecutionRecord): Boolean { + if (!candidate.ownsNamespaceAuthority()) return false + val allAttempts = attempts(candidate.clientId).associateBy { attempt -> attempt.key() } + val namespace = namespaceFor(candidate, allAttempts) + val owners = + executions(candidate.clientId).filter { execution -> + execution.ownsNamespaceAuthority() && + namespaceFor(execution, allAttempts) == namespace + } + return owners.size == 1 && + owners.single().clientSequence == candidate.clientSequence + } + + private fun namespaceFor( + execution: MutationExecutionRecord, + attempts: Map, + ): String = + requireNotNull( + attempts[ + AttemptKey( + execution.clientId, + execution.clientSequence, + execution.currentGeneration, + ) + ], + ) { "namespace authority requires the exact current attempt" }.effectiveNamespace + + private fun activeRouteFrom( + source: IdentityKey, + aliases: List, + ): Set { + val aliasesBySource = + aliases.associateBy { alias -> + IdentityKey(alias.sourceNamespace, alias.sourceCanonicalId) + } + val route = linkedSetOf() + var cursor = source + while (route.add(cursor)) { + val alias = aliasesBySource[cursor] ?: return route + if (alias.state != MutationAliasState.ACTIVE) return route + cursor = IdentityKey(alias.targetNamespace, alias.targetCanonicalId) + } + throw IllegalArgumentException("an active alias route cannot contain a cycle") + } + + private fun insertTombstoneRow(record: MutationKeyTombstoneRecord) { + execute( + """INSERT INTO store6_key_tombstone( + namespace, canonical_id, created_by_client_id, created_by_sequence, state, + created_at, activated_at, superseded_by_client_id, superseded_by_sequence, + superseded_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + 10, + ) { + bindString(0, record.namespace) + bindString(1, record.canonicalId) + bindString(2, record.createdByClientId) + bindLong(3, record.createdBySequence) + bindString(4, record.state.name) + bindLong(5, record.createdAt) + bindLong(6, record.activatedAt) + bindString(7, record.supersededByClientId) + bindLong(8, record.supersededBySequence) + bindLong(9, record.supersededAt) + }.requireOneRow("tombstone insert") + } + + private fun wouldCreateAliasCycle(record: MutationKeyAliasRecord): Boolean { + val rows = aliases().associateBy { IdentityKey(it.sourceNamespace, it.sourceCanonicalId) } + val source = IdentityKey(record.sourceNamespace, record.sourceCanonicalId) + var cursor = IdentityKey(record.targetNamespace, record.targetCanonicalId) + val visited = mutableSetOf() + while (visited.add(cursor)) { + if (cursor == source) return true + val next = rows[cursor] ?: return false + cursor = IdentityKey(next.targetNamespace, next.targetCanonicalId) + } + return true + } + + private fun requireNoOtherTombstoneInState( + record: MutationKeyTombstoneRecord, + state: MutationTombstoneState, + excluding: MutationKeyTombstoneRecord?, + ) { + if (state == MutationTombstoneState.SUPERSEDED) return + require( + tombstones().none { existing -> + (excluding == null || !existing.sameKeyAs(excluding)) && + existing.namespace == record.namespace && + existing.canonicalId == record.canonicalId && + existing.state == state + }, + ) { "effective identity already has a $state tombstone generation" } + } + + private fun MutationKeyTombstoneRecord.supersedingIntentIsConfirmed(): Boolean { + val successorClientId = supersededByClientId ?: return false + val successorSequence = supersededBySequence ?: return false + val successorClient = client(successorClientId) ?: return false + return successorSequence <= successorClient.serverConfirmedRetiredThroughSequence + } + + private fun SqlPreparedStatement.bindAttempt(record: MutationAttemptRecord) { + bindString(0, record.clientId) + bindLong(1, record.clientSequence) + bindLong(2, record.generation.toLong()) + bindString(3, record.effectiveNamespace) + bindString(4, record.effectiveCanonicalId) + bindLong(5, record.valueCodecVersion.toLong()) + bindString(6, record.basePresence.name) + bindBytes(7, record.baseBlob?.copyOf()) + bindString(8, record.minePresence.name) + bindBytes(9, record.mineBlob?.copyOf()) + bindBoolean(10, record.preconditionMetaPresent) + bindLong(11, record.preconditionWrittenAt) + bindString(12, record.preconditionEtag) + bindLong(13, record.advertisedRetiredThroughSequence) + bindString(14, record.generationIdempotencyKey) + bindLong(15, record.preparedAt) + bindBoolean(16, record.conflictMetaPresent) + bindLong(17, record.conflictWrittenAt) + bindString(18, record.conflictEtag) + bindLong(19, record.conflictReceivedAt) + } + + private fun requireActive() { + check(active) { "mutation journal transaction is no longer active" } + } + + private fun execute( + sql: String, + parameters: Int = 0, + binders: SqlPreparedStatement.() -> Unit = {}, + ): Long = driver.execute(null, sql, parameters, binders).value + + private fun exists( + sql: String, + parameters: Int, + binders: SqlPreparedStatement.() -> Unit, + ): Boolean = queryOne(sql, parameters, binders) { true } == true + + private fun queryLong(sql: String): Long? = queryOne(sql) { cursor -> cursor.getLong(0) } + + private fun queryOne( + sql: String, + parameters: Int = 0, + binders: SqlPreparedStatement.() -> Unit = {}, + mapper: (SqlCursor) -> T, + ): T? = + driver.executeQuery( + null, + sql, + { cursor -> QueryResult.Value(if (cursor.next().value) mapper(cursor) else null) }, + parameters, + binders, + ).value + + private fun queryList( + sql: String, + parameters: Int = 0, + binders: SqlPreparedStatement.() -> Unit = {}, + mapper: (SqlCursor) -> T, + ): List = + driver.executeQuery( + null, + sql, + { cursor -> + val rows = mutableListOf() + while (cursor.next().value) rows += mapper(cursor) + QueryResult.Value(rows) + }, + parameters, + binders, + ).value +} + +private fun SqlCursor.toIntent(): MutationIntentRecord = + MutationIntentRecord( + rowId = requiredLong(0), + recordVersion = requiredInt(1, "intent record version"), + clientId = requiredString(2), + clientSequence = requiredLong(3), + mutationId = requiredString(4), + namespace = requiredString(5), + canonicalId = requiredString(6), + mutatorId = requiredString(7), + mutatorVersion = requiredInt(8, "mutator version"), + argsBlob = requireNotNull(getBytes(9)).copyOf(), + idempotencyRoot = requiredString(10), + createdAt = requiredLong(11), + ) + +private fun SqlCursor.toAttempt(): MutationAttemptRecord = + MutationAttemptRecord( + clientId = requiredString(0), + clientSequence = requiredLong(1), + generation = requiredInt(2, "attempt generation"), + effectiveNamespace = requiredString(3), + effectiveCanonicalId = requiredString(4), + valueCodecVersion = requiredInt(5, "attempt value codec version"), + basePresence = enumNamed(requiredString(6), MutationPresenceState.entries, "presence"), + baseBlob = getBytes(7)?.copyOf(), + minePresence = enumNamed(requiredString(8), MutationPresenceState.entries, "presence"), + mineBlob = getBytes(9)?.copyOf(), + preconditionMetaPresent = requireNotNull(getBoolean(10)), + preconditionWrittenAt = getLong(11), + preconditionEtag = getString(12), + advertisedRetiredThroughSequence = requiredLong(13), + generationIdempotencyKey = requiredString(14), + preparedAt = requiredLong(15), + conflictMetaPresent = getBoolean(16), + conflictWrittenAt = getLong(17), + conflictEtag = getString(18), + conflictReceivedAt = getLong(19), + ) + +private fun SqlCursor.toAck(): MutationAckRecord = + MutationAckRecord( + clientId = requiredString(0), + clientSequence = requiredLong(1), + generation = requiredInt(2, "acknowledgement generation"), + authoritativePresence = + enumNamed(requiredString(3), MutationPresenceState.entries, "presence"), + authoritativeBlob = getBytes(4)?.copyOf(), + valueCodecVersion = requiredInt(5, "acknowledgement value codec version"), + etag = getString(6), + canonicalTargetNamespace = getString(7), + canonicalTargetId = getString(8), + receivedAt = requiredLong(9), + ) + +private fun SqlCursor.toFailure(): MutationFailureRecord = + MutationFailureRecord( + failureId = requiredLong(0), + clientId = requiredString(1), + clientSequence = requiredLong(2), + generation = requiredInt(3, "failure generation"), + kind = enumNamed(requiredString(4), MutationFailureKind.entries, "failure kind"), + detail = requiredString(5), + message = requiredString(6), + occurredAt = requiredLong(7), + ) + +private fun SqlCursor.toEffect(): MutationEffectRecord = + MutationEffectRecord( + clientId = requiredString(0), + clientSequence = requiredLong(1), + effectIndex = requiredInt(2, "effect index"), + kind = enumNamed(requiredString(3), MutationEffectKind.entries, "effect kind"), + namespace = requiredString(4), + canonicalId = getString(5), + createdAt = requiredLong(6), + disposition = + enumNamed(requiredString(7), MutationEffectDisposition.entries, "effect disposition"), + completedAt = getLong(8), + ) + +private fun SqlCursor.toAlias(): MutationKeyAliasRecord = + MutationKeyAliasRecord( + sourceNamespace = requiredString(0), + sourceCanonicalId = requiredString(1), + targetNamespace = requiredString(2), + targetCanonicalId = requiredString(3), + state = enumNamed(requiredString(4), MutationAliasState.entries, "alias state"), + createdByClientId = requiredString(5), + createdBySequence = requiredLong(6), + createdAt = requiredLong(7), + activatedAt = getLong(8), + ) + +private fun SqlCursor.toTombstone(): MutationKeyTombstoneRecord = + MutationKeyTombstoneRecord( + namespace = requiredString(0), + canonicalId = requiredString(1), + createdByClientId = requiredString(2), + createdBySequence = requiredLong(3), + state = enumNamed(requiredString(4), MutationTombstoneState.entries, "tombstone state"), + createdAt = requiredLong(5), + activatedAt = getLong(6), + supersededByClientId = getString(7), + supersededBySequence = getLong(8), + supersededAt = getLong(9), + ) + +private fun SqlCursor.requiredLong(index: Int): Long = + requireNotNull(getLong(index)) { "persisted journal column $index was unexpectedly null" } + +private fun SqlCursor.requiredInt( + index: Int, + label: String, +): Int { + val value = requiredLong(index) + check(value in Int.MIN_VALUE.toLong()..Int.MAX_VALUE.toLong()) { + "Persisted $label value $value is outside the Kotlin Int range" + } + return value.toInt() +} + +private fun SqlCursor.requiredString(index: Int): String = + requireNotNull(getString(index)) { "persisted journal column $index was unexpectedly null" } + +private fun > enumNamed( + name: String, + entries: List, + label: String, +): E = + checkNotNull(entries.firstOrNull { entry -> entry.name == name }) { + "Unknown persisted $label name '$name'" + } + +private fun Long.requireOneRow(operation: String) { + check(this == 1L) { "$operation affected $this rows instead of one" } +} + +private data class IdentityKey( + val namespace: String, + val canonicalId: String, +) + +private data class AttemptKey( + val clientId: String, + val sequence: Long, + val generation: Int, +) + +private data class SuccessorKey( + val clientId: String, + val sequence: Long, +) + +private data class ClientTransition( + val previous: MutationClientRecord, + val next: MutationClientRecord, +) + +private data class ExecutionTransition( + val previous: MutationExecutionRecord, + val next: MutationExecutionRecord, + val wasUniqueNamespaceOwner: Boolean, +) + +private data class AliasTransition( + val previous: MutationKeyAliasRecord, + val next: MutationKeyAliasRecord, +) + +private data class TombstoneTransition( + val previous: MutationKeyTombstoneRecord, + val next: MutationKeyTombstoneRecord, +) { + fun isSameClientLowerSuccessor(): Boolean = + next.state == MutationTombstoneState.SUPERSEDED && + next.supersededByClientId == previous.createdByClientId && + requireNotNull(next.supersededBySequence) < previous.createdBySequence +} + +private fun MutationAttemptRecord.key(): AttemptKey = + AttemptKey(clientId, clientSequence, generation) + +private fun MutationExecutionRecord.ownsNamespaceAuthority(): Boolean = + when (phase) { + MutationExecutionPhase.INFLIGHT, + MutationExecutionPhase.REFRESH_REQUIRED, + MutationExecutionPhase.ACKED, + MutationExecutionPhase.EFFECTS_PENDING, + -> true + + MutationExecutionPhase.READY -> attempt > 0 || currentGeneration > 1 + MutationExecutionPhase.UNPREPARED, + MutationExecutionPhase.PARKED, + MutationExecutionPhase.RETIRED, + -> false + } + +private fun requireExecutionTransition( + previous: MutationExecutionRecord, + next: MutationExecutionRecord, + activeFailureKind: MutationFailureKind?, +) { + require(previous.clientId == next.clientId && previous.clientSequence == next.clientSequence) { + "execution identity is immutable" + } + require(next.attempt >= previous.attempt || next.currentGeneration > previous.currentGeneration) { + "completed-attempt count cannot regress within a generation" + } + val generationAdvanced = next.currentGeneration > previous.currentGeneration + if (generationAdvanced) { + val initialPreparation = + previous.phase == MutationExecutionPhase.UNPREPARED && + previous.currentGeneration == 0 && + next.phase == MutationExecutionPhase.READY && + next.currentGeneration == 1 + val refreshRetry = + previous.phase == MutationExecutionPhase.REFRESH_REQUIRED && + next.phase == MutationExecutionPhase.READY && + next.currentGeneration == previous.currentGeneration + 1 + require( + (initialPreparation || refreshRetry) && next.attempt == 0 && next.lastAttemptAt == null, + ) { "generation may advance only for initial preparation or a g+1 refresh retry" } + } else { + require(next.currentGeneration == previous.currentGeneration) { + "generation cannot change on this phase edge" + } + } + if ( + previous.phase == MutationExecutionPhase.REFRESH_REQUIRED && + next.phase == MutationExecutionPhase.READY + ) { + require(generationAdvanced) { + "REFRESH_REQUIRED must persist generation g+1 before returning to READY" + } + } + val legalPhaseEdge = + when (previous.phase) { + MutationExecutionPhase.UNPREPARED -> + next.phase == MutationExecutionPhase.READY || next.phase == MutationExecutionPhase.PARKED + MutationExecutionPhase.READY -> + next.phase == MutationExecutionPhase.INFLIGHT || next.phase == MutationExecutionPhase.PARKED + MutationExecutionPhase.INFLIGHT -> + next.phase == MutationExecutionPhase.READY || + next.phase == MutationExecutionPhase.REFRESH_REQUIRED || + next.phase == MutationExecutionPhase.ACKED || + next.phase == MutationExecutionPhase.PARKED + MutationExecutionPhase.REFRESH_REQUIRED -> + next.phase == MutationExecutionPhase.READY || + next.phase == MutationExecutionPhase.RETIRED || + next.phase == MutationExecutionPhase.PARKED + MutationExecutionPhase.ACKED -> next.phase == MutationExecutionPhase.EFFECTS_PENDING + MutationExecutionPhase.EFFECTS_PENDING -> next.phase == MutationExecutionPhase.RETIRED + MutationExecutionPhase.PARKED, + MutationExecutionPhase.RETIRED, + -> false + } + require(legalPhaseEdge) { "illegal execution phase edge ${previous.phase} -> ${next.phase}" } + if (!generationAdvanced) { + val preservesPreTransportAttemptFacts = + previous.phase == MutationExecutionPhase.INFLIGHT && + next.phase == MutationExecutionPhase.PARKED && + next.attempt == previous.attempt && + next.lastAttemptAt == previous.lastAttemptAt && + ( + activeFailureKind == MutationFailureKind.IDENTITY || + activeFailureKind == MutationFailureKind.CODEC + ) + when { + preservesPreTransportAttemptFacts -> Unit + + previous.phase == MutationExecutionPhase.INFLIGHT -> { + require(next.attempt == previous.attempt + 1) { + "a completed INFLIGHT transition advances the attempt count once" + } + require(next.lastAttemptAt != null) { + "a completed INFLIGHT transition records lastAttemptAt" + } + } + else -> { + require(next.attempt == previous.attempt) { + "attempt count may change only when an INFLIGHT invocation completes" + } + require(next.lastAttemptAt == previous.lastAttemptAt) { + "lastAttemptAt may change only when an INFLIGHT invocation completes" + } + } + } + } +} + +private fun MutationExecutionRecord.sameValueAs(other: MutationExecutionRecord): Boolean = + clientId == other.clientId && clientSequence == other.clientSequence && phase == other.phase && + currentGeneration == other.currentGeneration && attempt == other.attempt && + lastAttemptAt == other.lastAttemptAt && activeFailureId == other.activeFailureId && + retiredAt == other.retiredAt + +private fun MutationAttemptRecord.samePreparationAs(other: MutationAttemptRecord): Boolean = + clientId == other.clientId && clientSequence == other.clientSequence && generation == other.generation && + effectiveNamespace == other.effectiveNamespace && + effectiveCanonicalId == other.effectiveCanonicalId && + valueCodecVersion == other.valueCodecVersion && basePresence == other.basePresence && + baseBlob.contentEqualsNullable(other.baseBlob) && minePresence == other.minePresence && + mineBlob.contentEqualsNullable(other.mineBlob) && + preconditionMetaPresent == other.preconditionMetaPresent && + preconditionWrittenAt == other.preconditionWrittenAt && + preconditionEtag == other.preconditionEtag && + advertisedRetiredThroughSequence == other.advertisedRetiredThroughSequence && + generationIdempotencyKey == other.generationIdempotencyKey && preparedAt == other.preparedAt + +private fun MutationAttemptRecord.sameValueAs(other: MutationAttemptRecord): Boolean = + samePreparationAs(other) && conflictMetaPresent == other.conflictMetaPresent && + conflictWrittenAt == other.conflictWrittenAt && conflictEtag == other.conflictEtag && + conflictReceivedAt == other.conflictReceivedAt + +private fun MutationAckRecord.sameValueAs(other: MutationAckRecord): Boolean = + clientId == other.clientId && clientSequence == other.clientSequence && generation == other.generation && + authoritativePresence == other.authoritativePresence && + authoritativeBlob.contentEqualsNullable(other.authoritativeBlob) && + valueCodecVersion == other.valueCodecVersion && etag == other.etag && + canonicalTargetNamespace == other.canonicalTargetNamespace && + canonicalTargetId == other.canonicalTargetId + +private fun MutationEffectRecord.sameIdentityAndTargetAs(other: MutationEffectRecord): Boolean = + clientId == other.clientId && clientSequence == other.clientSequence && + effectIndex == other.effectIndex && kind == other.kind && namespace == other.namespace && + canonicalId == other.canonicalId && createdAt == other.createdAt + +private fun MutationEffectRecord.sameValueAs(other: MutationEffectRecord): Boolean = + sameIdentityAndTargetAs(other) && disposition == other.disposition && completedAt == other.completedAt + +private fun MutationKeyAliasRecord.sameEdgeAs(other: MutationKeyAliasRecord): Boolean = + sourceNamespace == other.sourceNamespace && sourceCanonicalId == other.sourceCanonicalId && + targetNamespace == other.targetNamespace && targetCanonicalId == other.targetCanonicalId && + createdByClientId == other.createdByClientId && createdBySequence == other.createdBySequence && + createdAt == other.createdAt + +private fun MutationKeyAliasRecord.sameRouteAs(other: MutationKeyAliasRecord): Boolean = + sourceNamespace == other.sourceNamespace && sourceCanonicalId == other.sourceCanonicalId && + targetNamespace == other.targetNamespace && targetCanonicalId == other.targetCanonicalId + +private fun MutationKeyAliasRecord.sameValueAs(other: MutationKeyAliasRecord): Boolean = + sameEdgeAs(other) && state == other.state && activatedAt == other.activatedAt + +private fun MutationKeyTombstoneRecord.sameKeyAs(other: MutationKeyTombstoneRecord): Boolean = + namespace == other.namespace && canonicalId == other.canonicalId && + createdByClientId == other.createdByClientId && createdBySequence == other.createdBySequence + +private fun MutationKeyTombstoneRecord.sameGenerationAs(other: MutationKeyTombstoneRecord): Boolean = + sameKeyAs(other) && createdAt == other.createdAt + +private fun MutationKeyTombstoneRecord.sameValueAs(other: MutationKeyTombstoneRecord): Boolean = + sameGenerationAs(other) && state == other.state && activatedAt == other.activatedAt && + supersededByClientId == other.supersededByClientId && + supersededBySequence == other.supersededBySequence && supersededAt == other.supersededAt + +private fun ByteArray?.contentEqualsNullable(other: ByteArray?): Boolean = + when { + this == null -> other == null + other == null -> false + else -> contentEquals(other) + } + +private fun String.truncateUtf8(maxBytes: Int): String { + if (encodeToByteArray().size <= maxBytes) return this + val result = StringBuilder() + var index = 0 + while (index < length) { + val current = this[index] + val width = + if ( + current in '\uD800'..'\uDBFF' && index + 1 < length && + this[index + 1] in '\uDC00'..'\uDFFF' + ) { + 2 + } else { + 1 + } + val next = substring(index, index + width) + if ((result.toString() + next).encodeToByteArray().size > maxBytes) break + result.append(next) + index += width + } + return result.toString() +} diff --git a/store6-mutations-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/internal/MutationJournalSidecar.kt b/store6-mutations-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/internal/MutationJournalSidecar.kt new file mode 100644 index 000000000..a3a812d55 --- /dev/null +++ b/store6-mutations-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/internal/MutationJournalSidecar.kt @@ -0,0 +1,308 @@ +package org.mobilenativefoundation.store6.mutations.sqldelight.internal + +import app.cash.sqldelight.Transacter +import app.cash.sqldelight.db.QueryResult +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.db.SqlPreparedStatement + +/** Adapter-owned mutation-journal schema. SQLite `user_version` remains user-owned. */ +internal class MutationJournalSidecar( + private val driver: SqlDriver, + transacter: Transacter, +) { + init { + transacter.transaction { ensureSchema() } + } + + private fun ensureSchema() { + if (!mutationSchemaTableExists()) { + createSchemaV2() + return + } + + val version = queryLong("SELECT version FROM store6_mutation_schema WHERE id = 0") + if (version == null || version !in 1L..SCHEMA_VERSION) { + unsupportedSchemaVersion(version) + } + + when (version) { + 1L -> migrateV1ToV2() + SCHEMA_VERSION -> createSchemaV2() + } + } + + private fun createSchemaV2() { + executeAll( + """CREATE TABLE IF NOT EXISTS store6_mutation_schema( + id INTEGER NOT NULL PRIMARY KEY CHECK (id = 0), + version INTEGER NOT NULL)""", + """CREATE TABLE IF NOT EXISTS store6_mutation_client( + record_version INTEGER NOT NULL CHECK (record_version > 0), + client_id TEXT NOT NULL PRIMARY KEY, + last_allocated_sequence INTEGER NOT NULL, + retired_through_sequence INTEGER NOT NULL, + server_confirmed_retired_through_sequence INTEGER NOT NULL, + created_at INTEGER NOT NULL, + CHECK (0 <= server_confirmed_retired_through_sequence + AND server_confirmed_retired_through_sequence <= retired_through_sequence + AND retired_through_sequence <= last_allocated_sequence))""", + """CREATE TABLE IF NOT EXISTS store6_mutation_intent( + row_id INTEGER PRIMARY KEY, + record_version INTEGER NOT NULL CHECK (record_version > 0), + client_id TEXT NOT NULL, + client_sequence INTEGER NOT NULL CHECK (client_sequence > 0), + mutation_id TEXT NOT NULL, + namespace TEXT NOT NULL, + canonical_id TEXT NOT NULL, + mutator_id TEXT NOT NULL, + mutator_version INTEGER NOT NULL CHECK (mutator_version > 0), + args_blob BLOB NOT NULL, + idempotency_root TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + UNIQUE (client_id, client_sequence), + UNIQUE (client_id, mutation_id))""", + """CREATE TABLE IF NOT EXISTS store6_mutation_execution( + client_id TEXT NOT NULL, + client_sequence INTEGER NOT NULL CHECK (client_sequence > 0), + phase TEXT NOT NULL, + current_generation INTEGER NOT NULL CHECK (current_generation >= 0), + attempt INTEGER NOT NULL CHECK (attempt >= 0), + last_attempt_at INTEGER, + active_failure_id INTEGER, + retired_at INTEGER, + PRIMARY KEY (client_id, client_sequence), + CHECK ((attempt = 0 AND last_attempt_at IS NULL) + OR (attempt > 0 AND last_attempt_at IS NOT NULL)), + CHECK (current_generation > 0 + OR (attempt = 0 AND last_attempt_at IS NULL)), + CHECK ((phase = 'UNPREPARED' AND current_generation = 0) + OR phase = 'PARKED' + OR (phase <> 'UNPREPARED' AND current_generation > 0)), + CHECK ((phase = 'PARKED' AND active_failure_id IS NOT NULL) + OR (phase <> 'PARKED' AND active_failure_id IS NULL)), + CHECK ((phase = 'RETIRED' AND retired_at IS NOT NULL) + OR (phase <> 'RETIRED' AND retired_at IS NULL)))""", + """CREATE TABLE IF NOT EXISTS store6_mutation_attempt( + client_id TEXT NOT NULL, + client_sequence INTEGER NOT NULL CHECK (client_sequence > 0), + generation INTEGER NOT NULL CHECK (generation >= 1), + effective_namespace TEXT NOT NULL, + effective_canonical_id TEXT NOT NULL, + value_codec_version INTEGER NOT NULL CHECK (value_codec_version > 0), + base_presence TEXT NOT NULL, + base_blob BLOB, + mine_presence TEXT NOT NULL, + mine_blob BLOB, + precondition_meta_present INTEGER NOT NULL CHECK (precondition_meta_present IN (0, 1)), + precondition_written_at INTEGER, + precondition_etag TEXT, + advertised_retired_through_sequence INTEGER NOT NULL CHECK (advertised_retired_through_sequence >= 0), + generation_idempotency_key TEXT NOT NULL UNIQUE, + prepared_at INTEGER NOT NULL, + conflict_meta_present INTEGER CHECK (conflict_meta_present IN (0, 1)), + conflict_written_at INTEGER, + conflict_etag TEXT, + conflict_received_at INTEGER, + PRIMARY KEY (client_id, client_sequence, generation), + CHECK ((base_presence = 'PRESENT') = (base_blob IS NOT NULL)), + CHECK ((mine_presence = 'PRESENT') = (mine_blob IS NOT NULL)), + CHECK ((precondition_meta_present = 1 AND precondition_written_at IS NOT NULL) + OR (precondition_meta_present = 0 AND precondition_written_at IS NULL + AND precondition_etag IS NULL)), + CHECK ((conflict_meta_present IS NULL AND conflict_written_at IS NULL + AND conflict_etag IS NULL AND conflict_received_at IS NULL) + OR (conflict_meta_present = 0 AND conflict_written_at IS NULL + AND conflict_etag IS NULL AND conflict_received_at IS NOT NULL) + OR (conflict_meta_present = 1 AND conflict_written_at IS NOT NULL + AND conflict_received_at IS NOT NULL)))""", + """CREATE TABLE IF NOT EXISTS store6_mutation_ack( + client_id TEXT NOT NULL, + client_sequence INTEGER NOT NULL CHECK (client_sequence > 0), + generation INTEGER NOT NULL CHECK (generation >= 1), + authoritative_presence TEXT NOT NULL, + authoritative_blob BLOB, + value_codec_version INTEGER NOT NULL CHECK (value_codec_version > 0), + etag TEXT, + canonical_target_namespace TEXT, + canonical_target_id TEXT, + received_at INTEGER NOT NULL, + PRIMARY KEY (client_id, client_sequence, generation), + CHECK ((authoritative_presence = 'PRESENT') = + (authoritative_blob IS NOT NULL)), + CHECK ((canonical_target_namespace IS NULL) = (canonical_target_id IS NULL)), + CHECK (authoritative_presence = 'PRESENT' + OR canonical_target_namespace IS NULL))""", + """CREATE TABLE IF NOT EXISTS store6_mutation_failure( + failure_id INTEGER PRIMARY KEY, + client_id TEXT NOT NULL, + client_sequence INTEGER NOT NULL CHECK (client_sequence > 0), + generation INTEGER NOT NULL CHECK (generation >= 0), + kind TEXT NOT NULL, + detail TEXT NOT NULL CHECK (length(detail) <= 128), + message TEXT NOT NULL CHECK (length(message) <= 1024), + occurred_at INTEGER NOT NULL)""", + """CREATE TABLE IF NOT EXISTS store6_mutation_effect( + client_id TEXT NOT NULL, + client_sequence INTEGER NOT NULL CHECK (client_sequence > 0), + effect_index INTEGER NOT NULL CHECK (effect_index >= 0), + kind TEXT NOT NULL, + namespace TEXT NOT NULL, + canonical_id TEXT, + created_at INTEGER NOT NULL, + disposition TEXT NOT NULL, + completed_at INTEGER, + PRIMARY KEY (client_id, client_sequence, effect_index), + CHECK ((kind = 'KEY') = (canonical_id IS NOT NULL)), + CHECK ((disposition = 'PENDING' AND completed_at IS NULL) + OR (disposition <> 'PENDING' AND completed_at IS NOT NULL)))""", + """CREATE TABLE IF NOT EXISTS store6_key_alias( + source_namespace TEXT NOT NULL, + source_canonical_id TEXT NOT NULL, + target_namespace TEXT NOT NULL, + target_canonical_id TEXT NOT NULL, + state TEXT NOT NULL, + created_by_client_id TEXT NOT NULL, + created_by_sequence INTEGER NOT NULL CHECK (created_by_sequence > 0), + created_at INTEGER NOT NULL, + activated_at INTEGER, + PRIMARY KEY (source_namespace, source_canonical_id), + CHECK (source_namespace = target_namespace), + CHECK (source_canonical_id <> target_canonical_id), + CHECK ((state = 'ACTIVE' AND activated_at IS NOT NULL) + OR (state <> 'ACTIVE' AND activated_at IS NULL)))""", + """CREATE TABLE IF NOT EXISTS store6_key_tombstone( + namespace TEXT NOT NULL, + canonical_id TEXT NOT NULL, + created_by_client_id TEXT NOT NULL, + created_by_sequence INTEGER NOT NULL CHECK (created_by_sequence > 0), + state TEXT NOT NULL, + created_at INTEGER NOT NULL, + activated_at INTEGER, + superseded_by_client_id TEXT, + superseded_by_sequence INTEGER, + superseded_at INTEGER, + PRIMARY KEY (namespace, canonical_id, created_by_client_id, created_by_sequence), + CHECK ((superseded_by_client_id IS NULL) = (superseded_by_sequence IS NULL)), + CHECK (superseded_by_sequence IS NULL OR superseded_by_sequence > 0), + CHECK ((state = 'PENDING') = (activated_at IS NULL)), + CHECK ((state = 'SUPERSEDED') = (superseded_by_client_id IS NOT NULL)), + CHECK ((state = 'SUPERSEDED') = (superseded_at IS NOT NULL)))""", + """CREATE INDEX IF NOT EXISTS store6_mutation_intent_identity + ON store6_mutation_intent(namespace, canonical_id, client_id, client_sequence)""", + """CREATE INDEX IF NOT EXISTS store6_mutation_execution_phase + ON store6_mutation_execution(client_id, phase, client_sequence)""", + """CREATE INDEX IF NOT EXISTS store6_mutation_failure_prune + ON store6_mutation_failure(client_id, client_sequence, failure_id)""", + """CREATE INDEX IF NOT EXISTS store6_mutation_failure_order + ON store6_mutation_failure(client_id, failure_id)""", + """CREATE INDEX IF NOT EXISTS store6_key_tombstone_state + ON store6_key_tombstone(namespace, canonical_id, state)""", + """CREATE UNIQUE INDEX IF NOT EXISTS store6_key_tombstone_one_pending + ON store6_key_tombstone(namespace, canonical_id) WHERE state = 'PENDING'""", + """CREATE UNIQUE INDEX IF NOT EXISTS store6_key_tombstone_one_active + ON store6_key_tombstone(namespace, canonical_id) WHERE state = 'ACTIVE'""", + "INSERT OR IGNORE INTO store6_mutation_schema(id, version) VALUES (0, 2)", + ) + } + + private fun migrateV1ToV2() { + if (hasNonQuiescentNamespaceOwner()) { + error(MIGRATION_QUIESCENCE_DIAGNOSTIC) + } + + executeAll( + """CREATE TABLE store6_key_tombstone_v2( + namespace TEXT NOT NULL, + canonical_id TEXT NOT NULL, + created_by_client_id TEXT NOT NULL, + created_by_sequence INTEGER NOT NULL CHECK (created_by_sequence > 0), + state TEXT NOT NULL, + created_at INTEGER NOT NULL, + activated_at INTEGER, + superseded_by_client_id TEXT, + superseded_by_sequence INTEGER, + superseded_at INTEGER, + PRIMARY KEY (namespace, canonical_id, created_by_client_id, created_by_sequence), + CHECK ((superseded_by_client_id IS NULL) = (superseded_by_sequence IS NULL)), + CHECK (superseded_by_sequence IS NULL OR superseded_by_sequence > 0), + CHECK ((state = 'PENDING') = (activated_at IS NULL)), + CHECK ((state = 'SUPERSEDED') = (superseded_by_client_id IS NOT NULL)), + CHECK ((state = 'SUPERSEDED') = (superseded_at IS NOT NULL)))""", + """INSERT INTO store6_key_tombstone_v2( + namespace, canonical_id, created_by_client_id, created_by_sequence, + state, created_at, activated_at, superseded_by_client_id, + superseded_by_sequence, superseded_at) + SELECT namespace, canonical_id, created_by_client_id, created_by_sequence, + state, created_at, activated_at, superseded_by_client_id, + superseded_by_sequence, superseded_at + FROM store6_key_tombstone""", + "DROP TABLE store6_key_tombstone", + "ALTER TABLE store6_key_tombstone_v2 RENAME TO store6_key_tombstone", + """CREATE INDEX store6_key_tombstone_state + ON store6_key_tombstone(namespace, canonical_id, state)""", + """CREATE UNIQUE INDEX store6_key_tombstone_one_pending + ON store6_key_tombstone(namespace, canonical_id) WHERE state = 'PENDING'""", + """CREATE UNIQUE INDEX store6_key_tombstone_one_active + ON store6_key_tombstone(namespace, canonical_id) WHERE state = 'ACTIVE'""", + "UPDATE store6_mutation_schema SET version = 2 WHERE id = 0", + ) + } + + private fun mutationSchemaTableExists(): Boolean = + queryLong( + """SELECT 1 FROM sqlite_master + WHERE type = 'table' AND name = 'store6_mutation_schema' + LIMIT 1""", + ) != null + + private fun hasNonQuiescentNamespaceOwner(): Boolean = + queryLong( + """SELECT 1 FROM store6_mutation_execution + WHERE phase IN ('INFLIGHT', 'REFRESH_REQUIRED', 'ACKED', 'EFFECTS_PENDING') + OR (phase = 'READY' AND attempt > 0) + OR (phase = 'READY' AND current_generation > 1) + LIMIT 1""", + ) != null + + private fun unsupportedSchemaVersion(version: Long?): Nothing = + error( + "store6-mutations-sqldelight found mutation-journal schema version $version in this " + + "database, but this adapter supports up to $SCHEMA_VERSION. Upgrade the " + + "store6-mutations-sqldelight dependency for this database, or restore the " + + "database.", + ) + + private fun executeAll(vararg statements: String) { + statements.forEach(::execute) + } + + private fun execute( + sql: String, + parameters: Int = 0, + binders: SqlPreparedStatement.() -> Unit = {}, + ) { + driver.execute(null, sql, parameters, binders).value + } + + private fun queryLong(sql: String): Long? = + driver.executeQuery( + null, + sql, + { cursor -> + QueryResult.Value(if (cursor.next().value) cursor.getLong(0) else null) + }, + 0, + {}, + ).value + + private companion object { + const val SCHEMA_VERSION = 2L + + const val MIGRATION_QUIESCENCE_DIAGNOSTIC: String = + "store6-mutations-sqldelight cannot migrate mutation-journal schema version 1 to 2 " + + "because durable mutation namespaces are not quiescent. Downgrade the " + + "store6-mutations-sqldelight dependency to a version that supports schema " + + "version 1, drain or park/retire every non-quiescent mutation namespace, then " + + "retry the upgrade." + } +} diff --git a/store6-mutations-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/JournalHarness.kt b/store6-mutations-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/JournalHarness.kt new file mode 100644 index 000000000..e0e6dcd30 --- /dev/null +++ b/store6-mutations-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/JournalHarness.kt @@ -0,0 +1,146 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.mutations.sqldelight + +import app.cash.sqldelight.Transacter +import app.cash.sqldelight.TransacterImpl +import app.cash.sqldelight.db.AfterVersion +import app.cash.sqldelight.db.QueryResult +import app.cash.sqldelight.db.SqlCursor +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.db.SqlPreparedStatement +import app.cash.sqldelight.db.SqlSchema +import org.mobilenativefoundation.store6.mutations.storage.MutationJournalStorage + +internal class JournalHarness( + val driver: SqlDriver, +) { + val transacter: Transacter = JournalHarnessTransacter(driver) + + fun storage(): MutationJournalStorage = + SqlDelightMutationJournalStorage(driver, transacter) + + fun tableNames(): Set = + queryList( + """SELECT name FROM sqlite_master + WHERE type = 'table' + AND (name LIKE 'store6_mutation_%' OR name LIKE 'store6_key_%') + ORDER BY name""", + ) { cursor -> cursor.getString(0)!! }.toSet() + + fun tableColumnSignatures(table: String): List = + queryList("PRAGMA table_info($table)") { cursor -> + val name = cursor.getString(1)!! + val type = cursor.getString(2)!! + val notNull = cursor.getLong(3)!! + val primaryKeyPosition = cursor.getLong(5)!! + "$name:$type:$notNull:$primaryKeyPosition" + } + + fun explicitIndexSignatures(table: String): Set = + queryList("PRAGMA index_list($table)") { cursor -> + IndexHeader( + name = cursor.getString(1)!!, + unique = cursor.getLong(2)!!, + partial = cursor.getLong(4)!!, + ) + }.asSequence() + .filterNot { header -> header.name.startsWith("sqlite_autoindex_") } + .map { header -> + val columns = + queryList("PRAGMA index_info(${header.name})") { cursor -> + cursor.getString(2)!! + }.joinToString(",") + "${header.name}:unique=${header.unique}:partial=${header.partial}:$columns" + }.toSet() + + fun storedText( + table: String, + column: String, + ): Pair = + checkNotNull( + queryOne("SELECT typeof($column), $column FROM $table LIMIT 1") { cursor -> + cursor.getString(0)!! to cursor.getString(1)!! + }, + ) + + fun schemaVersion(): Long? = + queryOne("SELECT version FROM store6_mutation_schema WHERE id = 0") { cursor -> + cursor.getLong(0)!! + } + + fun setSchemaVersion(version: Long) { + execute("UPDATE store6_mutation_schema SET version = ? WHERE id = 0", 1) { + bindLong(0, version) + } + } + + fun userVersion(): Long = queryOne("PRAGMA user_version") { it.getLong(0)!! }!! + + fun setUserVersion(version: Long) { + execute("PRAGMA user_version = $version") + } + + fun executeRaw(sql: String) { + execute(sql) + } + + private fun execute( + sql: String, + parameters: Int = 0, + binders: SqlPreparedStatement.() -> Unit = {}, + ) { + driver.execute(null, sql, parameters, binders).value + } + + private fun queryOne( + sql: String, + mapper: (SqlCursor) -> T, + ): T? = + driver.executeQuery( + null, + sql, + { cursor -> QueryResult.Value(if (cursor.next().value) mapper(cursor) else null) }, + 0, + {}, + ).value + + private fun queryList( + sql: String, + mapper: (SqlCursor) -> T, + ): List = + driver.executeQuery( + null, + sql, + { cursor -> + val result = mutableListOf() + while (cursor.next().value) result += mapper(cursor) + QueryResult.Value(result) + }, + 0, + {}, + ).value +} + +private class IndexHeader( + val name: String, + val unique: Long, + val partial: Long, +) + +private class JournalHarnessTransacter(driver: SqlDriver) : TransacterImpl(driver) + +internal object JournalTestSchema : SqlSchema> { + override val version: Long = 1L + + override fun create(driver: SqlDriver): QueryResult.Value = QueryResult.Value(Unit) + + override fun migrate( + driver: SqlDriver, + oldVersion: Long, + newVersion: Long, + vararg callbacks: AfterVersion, + ): QueryResult.Value = QueryResult.Value(Unit) +} + +internal expect fun freshJournalHarness(): JournalHarness diff --git a/store6-mutations-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/SqlDelightJournalRestartTest.kt b/store6-mutations-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/SqlDelightJournalRestartTest.kt new file mode 100644 index 000000000..1141786fd --- /dev/null +++ b/store6-mutations-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/SqlDelightJournalRestartTest.kt @@ -0,0 +1,69 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.mutations.sqldelight + +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.mutations.storage.MutationClientRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationEffectDisposition +import org.mobilenativefoundation.store6.mutations.storage.MutationEffectKind +import org.mobilenativefoundation.store6.mutations.storage.MutationEffectRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionPhase +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionRecord +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals + +internal class SqlDelightJournalRestartTest { + @Test + fun durableAlgebraSurvivesAdapterRecreationOnSameDriver() = runTest { + val harness = freshJournalHarness() + try { + val first = harness.storage() + first.transaction { transaction -> + transaction.insertClient(MutationClientRecord(1, "client", 0, 0, 0, 10)) + transaction.advanceClient(MutationClientRecord(1, "client", 1, 0, 0, 10)) + transaction.insertIntent( + recordVersion = 1, + clientId = "client", + clientSequence = 1, + mutationId = "mutation-1", + namespace = "items", + canonicalId = "a", + mutatorId = "upsert", + mutatorVersion = 1, + argsBlob = byteArrayOf(1, 2, 3), + idempotencyRoot = "client:1", + createdAt = 11, + ) + transaction.insertExecution( + MutationExecutionRecord( + "client", 1, MutationExecutionPhase.UNPREPARED, 0, 0, null, null, null, + ), + ) + transaction.insertEffect( + MutationEffectRecord( + "client", 1, 0, MutationEffectKind.KEY, "items", "a", 12, + MutationEffectDisposition.PENDING, null, + ), + ) + } + + val reopened = harness.storage() + reopened.transaction { transaction -> + assertEquals(1L, transaction.client("client")?.lastAllocatedSequence) + assertEquals("mutation-1", transaction.intents("client").single().mutationId) + assertContentEquals( + byteArrayOf(1, 2, 3), + transaction.intents("client").single().argsBlob, + ) + assertEquals( + MutationExecutionPhase.UNPREPARED, + transaction.executions("client").single().phase, + ) + assertEquals(MutationEffectKind.KEY, transaction.effects("client").single().kind) + } + } finally { + harness.driver.close() + } + } +} diff --git a/store6-mutations-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/SqlDelightJournalStorageContractTest.kt b/store6-mutations-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/SqlDelightJournalStorageContractTest.kt new file mode 100644 index 000000000..ffe1dbc87 --- /dev/null +++ b/store6-mutations-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/SqlDelightJournalStorageContractTest.kt @@ -0,0 +1,45 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.mutations.sqldelight + +import org.mobilenativefoundation.store6.mutations.storage.MutationJournalStorage +import org.mobilenativefoundation.store6.mutations.testing.MutationJournalStorageContractKit +import kotlin.test.AfterTest + +internal class SqlDelightJournalStorageContractTest : MutationJournalStorageContractKit() { + private val harnesses = mutableListOf() + private val ownerByStorage = mutableMapOf() + + override fun createStorage(): MutationJournalStorage { + val harness = freshJournalHarness() + val storage = harness.storage() + harnesses += harness + ownerByStorage[storage] = harness + return storage + } + + override fun reopenStorage(previous: MutationJournalStorage): MutationJournalStorage { + val harness = checkNotNull(ownerByStorage[previous]) + val reopened = harness.storage() + ownerByStorage[reopened] = harness + return reopened + } + + @AfterTest + fun closeDrivers() { + var firstFailure: Throwable? = null + try { + harnesses.forEach { harness -> + try { + harness.driver.close() + } catch (failure: Throwable) { + if (firstFailure == null) firstFailure = failure + } + } + } finally { + harnesses.clear() + ownerByStorage.clear() + } + firstFailure?.let { throw it } + } +} diff --git a/store6-mutations-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/SqlDelightJournalStoragePhysicalTest.kt b/store6-mutations-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/SqlDelightJournalStoragePhysicalTest.kt new file mode 100644 index 000000000..af5ed4e66 --- /dev/null +++ b/store6-mutations-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/SqlDelightJournalStoragePhysicalTest.kt @@ -0,0 +1,279 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.mutations.sqldelight + +import app.cash.sqldelight.db.QueryResult +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.db.SqlPreparedStatement +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.mutations.MutationFailureKind +import org.mobilenativefoundation.store6.mutations.MutationPresenceState +import org.mobilenativefoundation.store6.mutations.storage.MutationAckRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationAliasState +import org.mobilenativefoundation.store6.mutations.storage.MutationAttemptRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationClientRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationEffectDisposition +import org.mobilenativefoundation.store6.mutations.storage.MutationEffectKind +import org.mobilenativefoundation.store6.mutations.storage.MutationEffectRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionPhase +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationIntentRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationKeyAliasRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationKeyTombstoneRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationTombstoneState +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.time.Duration.Companion.seconds + +internal class SqlDelightJournalStoragePhysicalTest { + @Test + fun persistedEnumsUsePhysicalTextNamesNeverOrdinals() = runTest(timeout = 25.seconds) { + withHarness { harness -> + val storage = harness.storage() + storage.transaction { transaction -> + transaction.insertClient(client(lastAllocatedSequence = 0L)) + transaction.advanceClient(client(lastAllocatedSequence = 1L)) + transaction.insertIntent( + recordVersion = 1, + clientId = CLIENT_ID, + clientSequence = 1L, + mutationId = "mutation-1", + namespace = "items", + canonicalId = "one", + mutatorId = "upsert", + mutatorVersion = 1, + argsBlob = byteArrayOf(1), + idempotencyRoot = "client:1", + createdAt = 2L, + ) + transaction.insertExecution(execution(MutationExecutionPhase.UNPREPARED, 0)) + transaction.insertAttempt(attempt()) + transaction.insertEffect(effect()) + transaction.advanceExecution(execution(MutationExecutionPhase.READY, 1)) + transaction.insertAck(ack()) + transaction.appendFailure( + clientId = CLIENT_ID, + clientSequence = 1L, + generation = 1, + kind = MutationFailureKind.PERSISTENCE, + detail = "detail", + message = "message", + occurredAt = 5L, + ) + transaction.insertAlias(alias()) + transaction.insertTombstone(tombstone()) + } + + val expected = + mapOf( + "store6_mutation_execution" to mapOf("phase" to "READY"), + "store6_mutation_attempt" to + mapOf("base_presence" to "ABSENT", "mine_presence" to "PRESENT"), + "store6_mutation_ack" to mapOf("authoritative_presence" to "PRESENT"), + "store6_mutation_failure" to mapOf("kind" to "PERSISTENCE"), + "store6_mutation_effect" to + mapOf("kind" to "KEY", "disposition" to "PENDING"), + "store6_key_alias" to mapOf("state" to "PENDING"), + "store6_key_tombstone" to mapOf("state" to "PENDING"), + ) + expected.forEach { (table, columns) -> + columns.forEach { (column, value) -> + assertEquals("text" to value, harness.storedText(table, column), "$table.$column") + } + } + } + } + + @Test + fun outOfRangePersistedIntFailsClosed() = runTest(timeout = 25.seconds) { + withHarness { harness -> + val storage = harness.storage() + harness.executeRaw( + """INSERT INTO store6_mutation_execution( + client_id, client_sequence, phase, current_generation, attempt, + last_attempt_at, active_failure_id, retired_at) + VALUES ('client', 1, 'READY', 4294967297, 0, NULL, NULL, NULL)""", + ) + + assertFailsWith { + storage.transaction { it.executions(CLIENT_ID) } + } + } + } + + @Test + fun insertIntent_copiesArgsBeforeDriverInteraction() = runTest(timeout = 25.seconds) { + val raw = freshJournalHarness() + val args = byteArrayOf(1, 2, 3) + val driver = IntentInsertHookDriver(raw.driver) { args[0] = 99 } + val harness = JournalHarness(driver) + try { + val storage = harness.storage() + lateinit var returned: MutationIntentRecord + storage.transaction { transaction -> + transaction.insertClient(client(lastAllocatedSequence = 0L)) + transaction.advanceClient(client(lastAllocatedSequence = 1L)) + returned = + transaction.insertIntent( + recordVersion = 1, + clientId = CLIENT_ID, + clientSequence = 1L, + mutationId = "mutation-1", + namespace = "items", + canonicalId = "one", + mutatorId = "upsert", + mutatorVersion = 1, + argsBlob = args, + idempotencyRoot = "client:1", + createdAt = 2L, + ) + } + + assertContentEquals(byteArrayOf(1, 2, 3), returned.argsBlob) + assertContentEquals( + byteArrayOf(1, 2, 3), + storage.transaction { it.intents(CLIENT_ID).single().argsBlob }, + ) + } finally { + driver.close() + } + } + + private suspend fun withHarness(block: suspend (JournalHarness) -> Unit) { + val harness = freshJournalHarness() + try { + block(harness) + } finally { + harness.driver.close() + } + } + + private fun client(lastAllocatedSequence: Long): MutationClientRecord = + MutationClientRecord( + recordVersion = 1, + clientId = CLIENT_ID, + lastAllocatedSequence = lastAllocatedSequence, + retiredThroughSequence = 0L, + serverConfirmedRetiredThroughSequence = 0L, + createdAt = 1L, + ) + + private fun execution( + phase: MutationExecutionPhase, + generation: Int, + ): MutationExecutionRecord = + MutationExecutionRecord( + clientId = CLIENT_ID, + clientSequence = 1L, + phase = phase, + currentGeneration = generation, + attempt = 0, + lastAttemptAt = null, + activeFailureId = null, + retiredAt = null, + ) + + private fun attempt(): MutationAttemptRecord = + MutationAttemptRecord( + clientId = CLIENT_ID, + clientSequence = 1L, + generation = 1, + effectiveNamespace = "items", + effectiveCanonicalId = "one", + valueCodecVersion = 1, + basePresence = MutationPresenceState.ABSENT, + baseBlob = null, + minePresence = MutationPresenceState.PRESENT, + mineBlob = byteArrayOf(2), + preconditionMetaPresent = false, + preconditionWrittenAt = null, + preconditionEtag = null, + advertisedRetiredThroughSequence = 0L, + generationIdempotencyKey = "client:1:g1", + preparedAt = 3L, + conflictMetaPresent = null, + conflictWrittenAt = null, + conflictEtag = null, + conflictReceivedAt = null, + ) + + private fun ack(): MutationAckRecord = + MutationAckRecord( + clientId = CLIENT_ID, + clientSequence = 1L, + generation = 1, + authoritativePresence = MutationPresenceState.PRESENT, + authoritativeBlob = byteArrayOf(3), + valueCodecVersion = 1, + etag = null, + canonicalTargetNamespace = null, + canonicalTargetId = null, + receivedAt = 4L, + ) + + private fun effect(): MutationEffectRecord = + MutationEffectRecord( + clientId = CLIENT_ID, + clientSequence = 1L, + effectIndex = 0, + kind = MutationEffectKind.KEY, + namespace = "items", + canonicalId = "one", + createdAt = 3L, + disposition = MutationEffectDisposition.PENDING, + completedAt = null, + ) + + private fun alias(): MutationKeyAliasRecord = + MutationKeyAliasRecord( + sourceNamespace = "items", + sourceCanonicalId = "one", + targetNamespace = "items", + targetCanonicalId = "two", + state = MutationAliasState.PENDING, + createdByClientId = CLIENT_ID, + createdBySequence = 1L, + createdAt = 4L, + activatedAt = null, + ) + + private fun tombstone(): MutationKeyTombstoneRecord = + MutationKeyTombstoneRecord( + namespace = "items", + canonicalId = "one", + createdByClientId = CLIENT_ID, + createdBySequence = 1L, + state = MutationTombstoneState.PENDING, + createdAt = 4L, + activatedAt = null, + supersededByClientId = null, + supersededBySequence = null, + supersededAt = null, + ) + + private companion object { + const val CLIENT_ID = "client" + } +} + +private class IntentInsertHookDriver( + private val delegate: SqlDriver, + private val beforeIntentInsert: () -> Unit, +) : SqlDriver by delegate { + private var armed = true + + override fun execute( + identifier: Int?, + sql: String, + parameters: Int, + binders: (SqlPreparedStatement.() -> Unit)?, + ): QueryResult { + if (armed && "INSERT INTO store6_mutation_intent" in sql) { + armed = false + beforeIntentInsert() + } + return delegate.execute(identifier, sql, parameters, binders) + } +} diff --git a/store6-mutations-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/SqlDelightMutationRestartWalkingTest.kt b/store6-mutations-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/SqlDelightMutationRestartWalkingTest.kt new file mode 100644 index 000000000..516ffbe77 --- /dev/null +++ b/store6-mutations-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/SqlDelightMutationRestartWalkingTest.kt @@ -0,0 +1,222 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, +) + +package org.mobilenativefoundation.store6.mutations.sqldelight + +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import org.mobilenativefoundation.store6.mutations.MutationAck +import org.mobilenativefoundation.store6.mutations.MutationCodec +import org.mobilenativefoundation.store6.mutations.MutationKeyIdentity +import org.mobilenativefoundation.store6.mutations.MutationPresence +import org.mobilenativefoundation.store6.mutations.MutationPresentAck +import org.mobilenativefoundation.store6.mutations.MutationPush +import org.mobilenativefoundation.store6.mutations.MutationRetirement +import org.mobilenativefoundation.store6.mutations.MutationRetirementAck +import org.mobilenativefoundation.store6.mutations.MutationServer +import org.mobilenativefoundation.store6.mutations.MutationStore +import org.mobilenativefoundation.store6.mutations.MutatorRef +import org.mobilenativefoundation.store6.mutations.StaleSet +import org.mobilenativefoundation.store6.mutations.mutationStore +import org.mobilenativefoundation.store6.mutations.mutatorRegistry +import org.mobilenativefoundation.store6.mutations.storage.MutationJournalStorage +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +class SqlDelightMutationRestartWalkingTest { + @Test + fun offlineEnqueue_newAdapterShowsOverlayBeforeConnectivity_thenConfirms() = + runTest(timeout = 25.seconds) { + val harness = freshJournalHarness() + val sourceOfTruth = RestartSqlSourceOfTruth() + val key = RestartSqlKey("restart-walking") + val server = RestartSqlServer() + lateinit var append: MutatorRef + val registry = + mutatorRegistry { + append = + upsert( + id = "append", + version = 1, + codec = RestartSqlStringCodec, + stales = { _, _ -> StaleSet(emptySet(), emptySet()) }, + ) { base, suffix -> + val value = (base as? MutationPresence.Present)?.value.orEmpty() + MutationPresence.Present(value + suffix) + } + } + + try { + sourceOfTruth.write(key, "base") + openRestartSqlStore(harness.storage(), sourceOfTruth, registry, server).use { first -> + first.mutate(key, append, "+mine") + first.drain(key) + assertEquals(listOf("push:1"), server.calls) + assertEquals(listOf("mutation-1"), first.pending(key).map { it.mutationId }) + } + val callsBeforeRestart = server.calls.toList() + + openRestartSqlStore(harness.storage(), sourceOfTruth, registry, server).use { reopened -> + val emissions = Channel>(Channel.UNLIMITED) + val collection = + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + reopened.stream(key, Freshness.LocalOnly).collect(emissions::send) + } + try { + val optimistic = + emissions.awaitSqlData { data -> + data.value == "base+mine" && data.origin == Origin.OVERLAY + } + assertEquals("base+mine", optimistic.value) + assertTrue(!server.online) + assertEquals(callsBeforeRestart, server.calls) + + server.online = true + reopened.drain(key) + + val confirmed = + emissions.awaitSqlData { data -> + data.value == "confirmed:base+mine" && data.origin != Origin.OVERLAY + } + assertEquals("confirmed:base+mine", confirmed.value) + assertEquals(2, server.calls.count { call -> call == "push:1" }) + assertEquals(emptyList(), reopened.pending(key)) + } finally { + collection.cancelAndJoin() + emissions.cancel() + } + } + } finally { + harness.driver.close() + } + } +} + +private fun openRestartSqlStore( + storage: MutationJournalStorage, + sourceOfTruth: RestartSqlSourceOfTruth, + registry: org.mobilenativefoundation.store6.mutations.MutatorRegistry, + server: MutationServer, +): MutationStore = + mutationStore( + registry = registry, + server = server, + keyResolver = { identity: MutationKeyIdentity -> + if (identity.namespace == RestartSqlKey.NAMESPACE.value) { + RestartSqlKey(identity.canonicalId) + } else { + null + } + }, + valueCodecVersion = 1, + valueCodec = RestartSqlStringCodec, + ) { + fetcher { error("The SQLDelight AC-4 LocalOnly scenario must not fetch") } + persistence(sourceOfTruth) + journalStorage(storage) + } + +private class RestartSqlKey( + private val id: String, +) : StoreKey { + override val namespace: StoreNamespace = NAMESPACE + + override fun canonicalId(): String = id + + companion object { + val NAMESPACE: StoreNamespace = StoreNamespace("restart-sql") + } +} + +private object RestartSqlStringCodec : MutationCodec { + override fun encode(value: String): ByteArray = value.encodeToByteArray() + + override fun decode( + version: Int, + bytes: ByteArray, + ): String = bytes.decodeToString() +} + +private class RestartSqlSourceOfTruth : SourceOfTruth { + private val row = MutableStateFlow(null) + + override fun reader(key: RestartSqlKey): Flow = row + + override suspend fun write( + key: RestartSqlKey, + value: String, + ) { + row.value = value + } + + override suspend fun delete(key: RestartSqlKey) { + row.value = null + } + + override suspend fun deleteNamespace(namespace: StoreNamespace) { + if (namespace == RestartSqlKey.NAMESPACE) row.value = null + } + + override suspend fun deleteAll() { + row.value = null + } +} + +private class RestartSqlServer : MutationServer { + var online: Boolean = false + val calls: MutableList = mutableListOf() + + override suspend fun push( + request: MutationPush, + ): MutationAck { + calls += "push:${request.clientSequence}" + check(online) { "server is offline" } + val mine = (request.mine as MutationPresence.Present).value + return MutationPresentAck( + authoritative = "confirmed:$mine", + etag = "etag-${request.clientSequence}", + canonicalKey = null, + ) + } + + override suspend fun retire(request: MutationRetirement): MutationRetirementAck { + calls += "retire:${request.retiredThroughSequence}" + check(online) { "server is offline" } + return MutationRetirementAck(request.retiredThroughSequence) + } +} + +private suspend fun ReceiveChannel>.awaitSqlData( + predicate: (StoreResult.Data) -> Boolean, +): StoreResult.Data { + while (true) { + val result = receive() + if (result is StoreResult.Data && predicate(result)) return result + } +} + +private inline fun MutationStore.use( + block: (MutationStore) -> R, +): R = + try { + block(this) + } finally { + close() + } diff --git a/store6-mutations-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/internal/MutationJournalSidecarTest.kt b/store6-mutations-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/internal/MutationJournalSidecarTest.kt new file mode 100644 index 000000000..847528264 --- /dev/null +++ b/store6-mutations-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/internal/MutationJournalSidecarTest.kt @@ -0,0 +1,751 @@ +package org.mobilenativefoundation.store6.mutations.sqldelight.internal + +import app.cash.sqldelight.db.QueryResult +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.db.SqlPreparedStatement +import org.mobilenativefoundation.store6.mutations.sqldelight.JournalHarness +import org.mobilenativefoundation.store6.mutations.sqldelight.freshJournalHarness +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals + +internal class MutationJournalSidecarTest { + @Test + fun schemaInitializationCreatesTheFrozenModelAndLeavesUserVersionUntouched() { + withHarness { harness -> + harness.setUserVersion(73L) + + MutationJournalSidecar(harness.driver, harness.transacter) + + assertEquals(2L, harness.schemaVersion()) + assertFrozenSchema(harness) + assertV2TombstoneSchema(harness) + assertEquals(73L, harness.userVersion()) + + val firstOpen = harness.databaseSnapshot() + MutationJournalSidecar(harness.driver, harness.transacter) + assertEquals(firstOpen, harness.databaseSnapshot()) + } + + withHarness { harness -> + harness.setUserVersion(73L) + harness.installSchemaV1() + harness.seedQuiescentExecutions() + harness.seedActiveTombstone() + + val v1Tables = harness.tableDefinitions() + val v1Rows = harness.seededDataSnapshot() + + MutationJournalSidecar(harness.driver, harness.transacter) + + assertEquals(2L, harness.schemaVersion()) + assertFrozenSchema(harness) + assertV2TombstoneSchema(harness) + assertEquals( + v1Tables - "store6_key_tombstone", + harness.tableDefinitions() - "store6_key_tombstone", + ) + assertNotEquals( + canonicalTombstoneSql(v1Tables.getValue("store6_key_tombstone")), + canonicalTombstoneSql(harness.tableSql("store6_key_tombstone")), + ) + assertEquals(v1Rows, harness.seededDataSnapshot()) + assertEquals(73L, harness.userVersion()) + + val migrated = harness.databaseSnapshot() + MutationJournalSidecar(harness.driver, harness.transacter) + assertEquals(migrated, harness.databaseSnapshot()) + } + } + + @Test + fun newerStoredSchemaVersionFailsFastWithUpgradeOrRestoreDiagnostic() { + withHarness { harness -> + MutationJournalSidecar(harness.driver, harness.transacter) + harness.setUserVersion(73L) + harness.setSchemaVersion(3L) + val before = harness.databaseSnapshot() + + val failure = + assertFailsWith { + MutationJournalSidecar(harness.driver, harness.transacter) + } + + assertEquals( + "store6-mutations-sqldelight found mutation-journal schema version 3 in this " + + "database, but this adapter supports up to 2. Upgrade the " + + "store6-mutations-sqldelight dependency for this database, or restore the " + + "database.", + failure.message, + ) + assertEquals(before, harness.databaseSnapshot()) + } + + NON_QUIESCENT_EXECUTIONS.forEach { owner -> + withHarness { harness -> + harness.setUserVersion(73L) + harness.installSchemaV1() + harness.seedExecution(owner) + val before = harness.databaseSnapshot() + + val failure = + assertFailsWith(owner.label) { + MutationJournalSidecar(harness.driver, harness.transacter) + } + + assertEquals(MIGRATION_QUIESCENCE_DIAGNOSTIC, failure.message, owner.label) + assertEquals(before, harness.databaseSnapshot(), owner.label) + assertEquals(1L, harness.schemaVersion(), owner.label) + assertEquals(73L, harness.userVersion(), owner.label) + } + } + + withHarness { harness -> + harness.setUserVersion(73L) + harness.installSchemaV1() + harness.seedQuiescentExecutions() + harness.seedActiveTombstone() + val before = harness.databaseSnapshot() + val failingDriver = FailAfterSchemaVersionWriteDriver(harness.driver) + val failingHarness = JournalHarness(failingDriver) + + assertFailsWith { + MutationJournalSidecar(failingDriver, failingHarness.transacter) + } + + assertEquals(before, harness.databaseSnapshot()) + assertEquals(1L, harness.schemaVersion()) + assertEquals(73L, harness.userVersion()) + } + } + + @Test + fun nonPositiveStoredSchemaVersionFailsFast() = + withHarness { harness -> + MutationJournalSidecar(harness.driver, harness.transacter) + harness.setSchemaVersion(0L) + + assertFailsWith { + MutationJournalSidecar(harness.driver, harness.transacter) + } + } + + @Test + fun schemaEnforcesLandedCarrierRelationsWithoutClosingAppendOnlyEnums() = + withHarness { harness -> + MutationJournalSidecar(harness.driver, harness.transacter) + + assertFails { + harness.executeRaw( + """INSERT INTO store6_mutation_execution( + client_id, client_sequence, phase, current_generation, attempt, + last_attempt_at, active_failure_id, retired_at) + VALUES ('client', 1, 'READY', 0, 1, NULL, NULL, NULL)""", + ) + } + harness.executeRaw( + """INSERT INTO store6_key_tombstone( + namespace, canonical_id, created_by_client_id, created_by_sequence, + state, created_at, activated_at, superseded_by_client_id, + superseded_by_sequence, superseded_at) + VALUES ('users', 'pending', 'client', 1, 'PENDING', 1, NULL, + NULL, NULL, NULL)""", + ) + assertFails { + harness.executeRaw( + """INSERT INTO store6_key_tombstone( + namespace, canonical_id, created_by_client_id, created_by_sequence, + state, created_at, activated_at, superseded_by_client_id, + superseded_by_sequence, superseded_at) + VALUES ('users', 'pending', 'client', 2, 'PENDING', 2, NULL, + NULL, NULL, NULL)""", + ) + } + assertFails { + harness.executeRaw( + """INSERT INTO store6_mutation_ack( + client_id, client_sequence, generation, authoritative_presence, + authoritative_blob, value_codec_version, etag, + canonical_target_namespace, canonical_target_id, received_at) + VALUES ('client', 1, 1, 'PRESENT', NULL, 1, NULL, NULL, NULL, 1)""", + ) + } + harness.executeRaw( + """INSERT INTO store6_key_tombstone( + namespace, canonical_id, created_by_client_id, created_by_sequence, + state, created_at, activated_at, superseded_by_client_id, + superseded_by_sequence, superseded_at) + VALUES ('users', 'a', 'client', 2, 'SUPERSEDED', 1, 2, + 'client', 1, 3)""", + ) + + harness.executeRaw( + """INSERT INTO store6_mutation_effect( + client_id, client_sequence, effect_index, kind, namespace, canonical_id, + created_at, disposition, completed_at) + VALUES ('client', 1, 0, 'FUTURE_KIND', 'users', NULL, 1, + 'FUTURE_DISPOSITION', 2)""", + ) + harness.executeRaw( + """INSERT INTO store6_key_tombstone( + namespace, canonical_id, created_by_client_id, created_by_sequence, + state, created_at, activated_at, superseded_by_client_id, + superseded_by_sequence, superseded_at) + VALUES ('users', 'future', 'client', 1, 'FUTURE_STATE', 1, 2, + NULL, NULL, NULL)""", + ) + } + + private fun assertFrozenSchema(harness: JournalHarness) { + assertEquals(EXPECTED_COLUMNS.keys, harness.tableNames()) + EXPECTED_COLUMNS.forEach { (table, columns) -> + assertEquals(columns, harness.tableColumnSignatures(table), table) + assertEquals(EXPECTED_INDEXES[table].orEmpty(), harness.explicitIndexSignatures(table), table) + } + } + + private fun assertV2TombstoneSchema(harness: JournalHarness) { + val actual = canonicalTombstoneSql(harness.tableSql("store6_key_tombstone")) + assertEquals(canonicalTombstoneSql(V2_TOMBSTONE_DDL), actual) + assertFalse(LOWER_LINK_CHECK in actual) + } + + private fun canonicalTombstoneSql(sql: String): String = + sql + .replace("\"store6_key_tombstone\"", "store6_key_tombstone") + .replace("IF NOT EXISTS ", "") + .replace(Regex("\\s+"), " ") + .trim() + + private fun JournalHarness.installSchemaV1() { + MutationJournalSidecar(driver, transacter) + executeRaw("DROP TABLE store6_key_tombstone") + executeRaw(V1_TOMBSTONE_DDL) + V1_TOMBSTONE_INDEX_DDL.forEach { statement -> executeRaw(statement) } + setSchemaVersion(1L) + } + + private fun JournalHarness.seedQuiescentExecutions() { + seedClient(lastAllocatedSequence = 4L) + seedIntent(sequence = 1L) + seedExecutionRow(sequence = 1L, seed = QUIESCENT_EXECUTIONS[0]) + seedIntent(sequence = 2L) + seedExecutionRow(sequence = 2L, seed = QUIESCENT_EXECUTIONS[1]) + seedIntent(sequence = 3L) + seedExecutionRow(sequence = 3L, seed = QUIESCENT_EXECUTIONS[2]) + seedIntent(sequence = 4L) + seedExecutionRow(sequence = 4L, seed = QUIESCENT_EXECUTIONS[3]) + seedRelatedDurableRows() + } + + private fun JournalHarness.seedRelatedDurableRows() { + executeRaw( + """INSERT INTO store6_mutation_attempt( + client_id, client_sequence, generation, effective_namespace, + effective_canonical_id, value_codec_version, base_presence, base_blob, + mine_presence, mine_blob, precondition_meta_present, precondition_written_at, + precondition_etag, advertised_retired_through_sequence, + generation_idempotency_key, prepared_at, conflict_meta_present, + conflict_written_at, conflict_etag, conflict_received_at) + VALUES ('client', 2, 1, 'users', 'key-2', 1, 'ABSENT', NULL, + 'PRESENT', X'02', 0, NULL, NULL, 0, 'idempotency-2-1', 2, + NULL, NULL, NULL, NULL)""", + ) + executeRaw( + """INSERT INTO store6_mutation_ack( + client_id, client_sequence, generation, authoritative_presence, + authoritative_blob, value_codec_version, etag, + canonical_target_namespace, canonical_target_id, received_at) + VALUES ('client', 4, 1, 'PRESENT', X'04', 1, 'etag-4', NULL, NULL, 4)""", + ) + executeRaw( + """INSERT INTO store6_mutation_failure( + failure_id, client_id, client_sequence, generation, kind, + detail, message, occurred_at) + VALUES (31, 'client', 3, 1, 'TRANSPORT', 'seeded', 'seeded failure', 3)""", + ) + executeRaw( + """INSERT INTO store6_mutation_effect( + client_id, client_sequence, effect_index, kind, namespace, + canonical_id, created_at, disposition, completed_at) + VALUES ('client', 4, 0, 'KEY', 'users', 'effect-4', 4, 'APPLIED', 5)""", + ) + executeRaw( + """INSERT INTO store6_key_alias( + source_namespace, source_canonical_id, target_namespace, target_canonical_id, + state, created_by_client_id, created_by_sequence, created_at, activated_at) + VALUES ('users', 'alias-source', 'users', 'alias-target', 'ACTIVE', + 'client', 4, 4, 5)""", + ) + } + + private fun JournalHarness.seedExecution(seed: ExecutionSeed) { + seedClient(lastAllocatedSequence = 1L) + seedIntent(sequence = 1L) + seedExecutionRow(sequence = 1L, seed = seed) + } + + private fun JournalHarness.seedClient(lastAllocatedSequence: Long) { + executeRaw( + """INSERT INTO store6_mutation_client( + record_version, client_id, last_allocated_sequence, + retired_through_sequence, server_confirmed_retired_through_sequence, created_at) + VALUES (1, 'client', $lastAllocatedSequence, 0, 0, 1)""", + ) + } + + private fun JournalHarness.seedIntent(sequence: Long) { + executeRaw( + """INSERT INTO store6_mutation_intent( + row_id, record_version, client_id, client_sequence, mutation_id, namespace, + canonical_id, mutator_id, mutator_version, args_blob, idempotency_root, created_at) + VALUES ($sequence, 1, 'client', $sequence, 'mutation-$sequence', 'users', + 'key-$sequence', 'mutator', 1, X'00', 'root-$sequence', 1)""", + ) + } + + private fun JournalHarness.seedExecutionRow( + sequence: Long, + seed: ExecutionSeed, + ) { + val lastAttemptAt = seed.lastAttemptAt?.toString() ?: "NULL" + val activeFailureId = seed.activeFailureId?.toString() ?: "NULL" + val retiredAt = seed.retiredAt?.toString() ?: "NULL" + executeRaw( + """INSERT INTO store6_mutation_execution( + client_id, client_sequence, phase, current_generation, attempt, + last_attempt_at, active_failure_id, retired_at) + VALUES ('client', $sequence, '${seed.phase}', ${seed.currentGeneration}, + ${seed.attempt}, $lastAttemptAt, $activeFailureId, $retiredAt)""", + ) + } + + private fun JournalHarness.seedActiveTombstone() { + executeRaw( + """INSERT INTO store6_key_tombstone( + namespace, canonical_id, created_by_client_id, created_by_sequence, + state, created_at, activated_at, superseded_by_client_id, + superseded_by_sequence, superseded_at) + VALUES ('users', 'seeded', 'client', 4, 'ACTIVE', 10, 11, + NULL, NULL, NULL)""", + ) + } + + private fun JournalHarness.tableDefinitions(): Map = + EXPECTED_COLUMNS.keys.associateWith { table -> tableSql(table) } + + private fun JournalHarness.tableSql(table: String): String = + queryTextList("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = '$table'").single() + + private fun JournalHarness.databaseSnapshot(): SidecarSnapshot = + SidecarSnapshot( + schemaObjects = schemaObjectSnapshot(), + schemaVersion = schemaVersion(), + seededRows = seededDataSnapshot(), + userVersion = userVersion(), + ) + + private fun JournalHarness.schemaObjectSnapshot(): List = + queryTextList( + """SELECT type || '|' || name || '|' || tbl_name || '|' || COALESCE(sql, '') + FROM sqlite_master + WHERE type IN ('table', 'index') + AND (tbl_name LIKE 'store6_mutation_%' OR tbl_name LIKE 'store6_key_%') + ORDER BY type, name""", + ) + + private fun JournalHarness.seededDataSnapshot(): List = + queryTextList( + """SELECT 'client|' || quote(record_version) || '|' || quote(client_id) || '|' || + quote(last_allocated_sequence) || '|' || quote(retired_through_sequence) || '|' || + quote(server_confirmed_retired_through_sequence) || '|' || quote(created_at) + FROM store6_mutation_client + UNION ALL + SELECT 'intent|' || quote(row_id) || '|' || quote(record_version) || '|' || + quote(client_id) || '|' || quote(client_sequence) || '|' || quote(mutation_id) || + '|' || quote(namespace) || '|' || quote(canonical_id) || '|' || quote(mutator_id) || + '|' || quote(mutator_version) || '|' || quote(args_blob) || '|' || + quote(idempotency_root) || '|' || quote(created_at) + FROM store6_mutation_intent + UNION ALL + SELECT 'execution|' || quote(client_id) || '|' || quote(client_sequence) || '|' || + quote(phase) || '|' || quote(current_generation) || '|' || quote(attempt) || '|' || + quote(last_attempt_at) || '|' || quote(active_failure_id) || '|' || quote(retired_at) + FROM store6_mutation_execution + UNION ALL + SELECT 'attempt|' || quote(client_id) || '|' || quote(client_sequence) || '|' || + quote(generation) || '|' || quote(effective_namespace) || '|' || + quote(effective_canonical_id) || '|' || quote(value_codec_version) || '|' || + quote(base_presence) || '|' || quote(base_blob) || '|' || quote(mine_presence) || + '|' || quote(mine_blob) || '|' || quote(precondition_meta_present) || '|' || + quote(precondition_written_at) || '|' || quote(precondition_etag) || '|' || + quote(advertised_retired_through_sequence) || '|' || + quote(generation_idempotency_key) || '|' || quote(prepared_at) || '|' || + quote(conflict_meta_present) || '|' || quote(conflict_written_at) || '|' || + quote(conflict_etag) || '|' || quote(conflict_received_at) + FROM store6_mutation_attempt + UNION ALL + SELECT 'ack|' || quote(client_id) || '|' || quote(client_sequence) || '|' || + quote(generation) || '|' || quote(authoritative_presence) || '|' || + quote(authoritative_blob) || '|' || quote(value_codec_version) || '|' || + quote(etag) || '|' || quote(canonical_target_namespace) || '|' || + quote(canonical_target_id) || '|' || quote(received_at) + FROM store6_mutation_ack + UNION ALL + SELECT 'failure|' || quote(failure_id) || '|' || quote(client_id) || '|' || + quote(client_sequence) || '|' || quote(generation) || '|' || quote(kind) || '|' || + quote(detail) || '|' || quote(message) || '|' || quote(occurred_at) + FROM store6_mutation_failure + UNION ALL + SELECT 'effect|' || quote(client_id) || '|' || quote(client_sequence) || '|' || + quote(effect_index) || '|' || quote(kind) || '|' || quote(namespace) || '|' || + quote(canonical_id) || '|' || quote(created_at) || '|' || quote(disposition) || + '|' || quote(completed_at) + FROM store6_mutation_effect + UNION ALL + SELECT 'alias|' || quote(source_namespace) || '|' || quote(source_canonical_id) || + '|' || quote(target_namespace) || '|' || quote(target_canonical_id) || '|' || + quote(state) || '|' || quote(created_by_client_id) || '|' || + quote(created_by_sequence) || '|' || quote(created_at) || '|' || quote(activated_at) + FROM store6_key_alias + UNION ALL + SELECT 'tombstone|' || quote(namespace) || '|' || quote(canonical_id) || '|' || + quote(created_by_client_id) || '|' || quote(created_by_sequence) || '|' || + quote(state) || '|' || quote(created_at) || '|' || quote(activated_at) || '|' || + quote(superseded_by_client_id) || '|' || quote(superseded_by_sequence) || '|' || + quote(superseded_at) + FROM store6_key_tombstone + ORDER BY 1""", + ) + + private fun JournalHarness.queryTextList(sql: String): List = + driver.executeQuery( + null, + sql, + { cursor -> + val result = mutableListOf() + while (cursor.next().value) result += cursor.getString(0)!! + QueryResult.Value(result) + }, + 0, + {}, + ).value + + private fun withHarness(block: (JournalHarness) -> Unit) { + val harness = freshJournalHarness() + try { + block(harness) + } finally { + harness.driver.close() + } + } + + private data class ExecutionSeed( + val label: String, + val phase: String, + val currentGeneration: Long, + val attempt: Long, + val lastAttemptAt: Long? = null, + val activeFailureId: Long? = null, + val retiredAt: Long? = null, + ) + + private data class SidecarSnapshot( + val schemaObjects: List, + val schemaVersion: Long?, + val seededRows: List, + val userVersion: Long, + ) + + private class ForcedMigrationFailure : RuntimeException("forced failure after schema-version write") + + private class FailAfterSchemaVersionWriteDriver( + private val delegate: SqlDriver, + ) : SqlDriver by delegate { + private var armed = true + + override fun execute( + identifier: Int?, + sql: String, + parameters: Int, + binders: (SqlPreparedStatement.() -> Unit)?, + ): QueryResult { + val result = delegate.execute(identifier, sql, parameters, binders) + if (armed && "UPDATE store6_mutation_schema SET version" in sql) { + armed = false + throw ForcedMigrationFailure() + } + return result + } + } + + private companion object { + const val MIGRATION_QUIESCENCE_DIAGNOSTIC: String = + "store6-mutations-sqldelight cannot migrate mutation-journal schema version 1 to 2 " + + "because durable mutation namespaces are not quiescent. Downgrade the " + + "store6-mutations-sqldelight dependency to a version that supports schema " + + "version 1, drain or park/retire every non-quiescent mutation namespace, then " + + "retry the upgrade." + + const val LOWER_LINK_CHECK: String = + "CHECK (superseded_by_client_id IS NULL " + + "OR superseded_by_client_id <> created_by_client_id " + + "OR superseded_by_sequence > created_by_sequence)" + + val V1_TOMBSTONE_DDL: String = + """CREATE TABLE store6_key_tombstone( + namespace TEXT NOT NULL, + canonical_id TEXT NOT NULL, + created_by_client_id TEXT NOT NULL, + created_by_sequence INTEGER NOT NULL CHECK (created_by_sequence > 0), + state TEXT NOT NULL, + created_at INTEGER NOT NULL, + activated_at INTEGER, + superseded_by_client_id TEXT, + superseded_by_sequence INTEGER, + superseded_at INTEGER, + PRIMARY KEY (namespace, canonical_id, created_by_client_id, created_by_sequence), + CHECK ((superseded_by_client_id IS NULL) = (superseded_by_sequence IS NULL)), + CHECK (superseded_by_sequence IS NULL OR superseded_by_sequence > 0), + CHECK (superseded_by_client_id IS NULL + OR superseded_by_client_id <> created_by_client_id + OR superseded_by_sequence > created_by_sequence), + CHECK ((state = 'PENDING') = (activated_at IS NULL)), + CHECK ((state = 'SUPERSEDED') = (superseded_by_client_id IS NOT NULL)), + CHECK ((state = 'SUPERSEDED') = (superseded_at IS NOT NULL)))""" + + val V2_TOMBSTONE_DDL: String = + """CREATE TABLE store6_key_tombstone( + namespace TEXT NOT NULL, + canonical_id TEXT NOT NULL, + created_by_client_id TEXT NOT NULL, + created_by_sequence INTEGER NOT NULL CHECK (created_by_sequence > 0), + state TEXT NOT NULL, + created_at INTEGER NOT NULL, + activated_at INTEGER, + superseded_by_client_id TEXT, + superseded_by_sequence INTEGER, + superseded_at INTEGER, + PRIMARY KEY (namespace, canonical_id, created_by_client_id, created_by_sequence), + CHECK ((superseded_by_client_id IS NULL) = (superseded_by_sequence IS NULL)), + CHECK (superseded_by_sequence IS NULL OR superseded_by_sequence > 0), + CHECK ((state = 'PENDING') = (activated_at IS NULL)), + CHECK ((state = 'SUPERSEDED') = (superseded_by_client_id IS NOT NULL)), + CHECK ((state = 'SUPERSEDED') = (superseded_at IS NOT NULL)))""" + + val V1_TOMBSTONE_INDEX_DDL: List = + listOf( + """CREATE INDEX store6_key_tombstone_state + ON store6_key_tombstone(namespace, canonical_id, state)""", + """CREATE UNIQUE INDEX store6_key_tombstone_one_pending + ON store6_key_tombstone(namespace, canonical_id) WHERE state = 'PENDING'""", + """CREATE UNIQUE INDEX store6_key_tombstone_one_active + ON store6_key_tombstone(namespace, canonical_id) WHERE state = 'ACTIVE'""", + ) + + val QUIESCENT_EXECUTIONS: List = + listOf( + ExecutionSeed("UNPREPARED", "UNPREPARED", currentGeneration = 0L, attempt = 0L), + ExecutionSeed("first-generation READY", "READY", currentGeneration = 1L, attempt = 0L), + ExecutionSeed( + "PARKED", + "PARKED", + currentGeneration = 1L, + attempt = 0L, + activeFailureId = 31L, + ), + ExecutionSeed( + "RETIRED", + "RETIRED", + currentGeneration = 1L, + attempt = 0L, + retiredAt = 4L, + ), + ) + + val NON_QUIESCENT_EXECUTIONS: List = + listOf( + ExecutionSeed("INFLIGHT", "INFLIGHT", currentGeneration = 1L, attempt = 0L), + ExecutionSeed( + "REFRESH_REQUIRED", + "REFRESH_REQUIRED", + currentGeneration = 1L, + attempt = 0L, + ), + ExecutionSeed("ACKED", "ACKED", currentGeneration = 1L, attempt = 0L), + ExecutionSeed( + "EFFECTS_PENDING", + "EFFECTS_PENDING", + currentGeneration = 1L, + attempt = 0L, + ), + ExecutionSeed( + "post-attempt READY", + "READY", + currentGeneration = 1L, + attempt = 1L, + lastAttemptAt = 2L, + ), + ExecutionSeed( + "generation > 1 READY", + "READY", + currentGeneration = 2L, + attempt = 0L, + ), + ) + + val EXPECTED_COLUMNS: Map> = + mapOf( + "store6_mutation_schema" to + listOf( + "id:INTEGER:1:1", + "version:INTEGER:1:0", + ), + "store6_mutation_client" to + listOf( + "record_version:INTEGER:1:0", + "client_id:TEXT:1:1", + "last_allocated_sequence:INTEGER:1:0", + "retired_through_sequence:INTEGER:1:0", + "server_confirmed_retired_through_sequence:INTEGER:1:0", + "created_at:INTEGER:1:0", + ), + "store6_mutation_intent" to + listOf( + "row_id:INTEGER:0:1", + "record_version:INTEGER:1:0", + "client_id:TEXT:1:0", + "client_sequence:INTEGER:1:0", + "mutation_id:TEXT:1:0", + "namespace:TEXT:1:0", + "canonical_id:TEXT:1:0", + "mutator_id:TEXT:1:0", + "mutator_version:INTEGER:1:0", + "args_blob:BLOB:1:0", + "idempotency_root:TEXT:1:0", + "created_at:INTEGER:1:0", + ), + "store6_mutation_execution" to + listOf( + "client_id:TEXT:1:1", + "client_sequence:INTEGER:1:2", + "phase:TEXT:1:0", + "current_generation:INTEGER:1:0", + "attempt:INTEGER:1:0", + "last_attempt_at:INTEGER:0:0", + "active_failure_id:INTEGER:0:0", + "retired_at:INTEGER:0:0", + ), + "store6_mutation_attempt" to + listOf( + "client_id:TEXT:1:1", + "client_sequence:INTEGER:1:2", + "generation:INTEGER:1:3", + "effective_namespace:TEXT:1:0", + "effective_canonical_id:TEXT:1:0", + "value_codec_version:INTEGER:1:0", + "base_presence:TEXT:1:0", + "base_blob:BLOB:0:0", + "mine_presence:TEXT:1:0", + "mine_blob:BLOB:0:0", + "precondition_meta_present:INTEGER:1:0", + "precondition_written_at:INTEGER:0:0", + "precondition_etag:TEXT:0:0", + "advertised_retired_through_sequence:INTEGER:1:0", + "generation_idempotency_key:TEXT:1:0", + "prepared_at:INTEGER:1:0", + "conflict_meta_present:INTEGER:0:0", + "conflict_written_at:INTEGER:0:0", + "conflict_etag:TEXT:0:0", + "conflict_received_at:INTEGER:0:0", + ), + "store6_mutation_ack" to + listOf( + "client_id:TEXT:1:1", + "client_sequence:INTEGER:1:2", + "generation:INTEGER:1:3", + "authoritative_presence:TEXT:1:0", + "authoritative_blob:BLOB:0:0", + "value_codec_version:INTEGER:1:0", + "etag:TEXT:0:0", + "canonical_target_namespace:TEXT:0:0", + "canonical_target_id:TEXT:0:0", + "received_at:INTEGER:1:0", + ), + "store6_mutation_failure" to + listOf( + "failure_id:INTEGER:0:1", + "client_id:TEXT:1:0", + "client_sequence:INTEGER:1:0", + "generation:INTEGER:1:0", + "kind:TEXT:1:0", + "detail:TEXT:1:0", + "message:TEXT:1:0", + "occurred_at:INTEGER:1:0", + ), + "store6_mutation_effect" to + listOf( + "client_id:TEXT:1:1", + "client_sequence:INTEGER:1:2", + "effect_index:INTEGER:1:3", + "kind:TEXT:1:0", + "namespace:TEXT:1:0", + "canonical_id:TEXT:0:0", + "created_at:INTEGER:1:0", + "disposition:TEXT:1:0", + "completed_at:INTEGER:0:0", + ), + "store6_key_alias" to + listOf( + "source_namespace:TEXT:1:1", + "source_canonical_id:TEXT:1:2", + "target_namespace:TEXT:1:0", + "target_canonical_id:TEXT:1:0", + "state:TEXT:1:0", + "created_by_client_id:TEXT:1:0", + "created_by_sequence:INTEGER:1:0", + "created_at:INTEGER:1:0", + "activated_at:INTEGER:0:0", + ), + "store6_key_tombstone" to + listOf( + "namespace:TEXT:1:1", + "canonical_id:TEXT:1:2", + "created_by_client_id:TEXT:1:3", + "created_by_sequence:INTEGER:1:4", + "state:TEXT:1:0", + "created_at:INTEGER:1:0", + "activated_at:INTEGER:0:0", + "superseded_by_client_id:TEXT:0:0", + "superseded_by_sequence:INTEGER:0:0", + "superseded_at:INTEGER:0:0", + ), + ) + + val EXPECTED_INDEXES: Map> = + mapOf( + "store6_mutation_intent" to + setOf( + "store6_mutation_intent_identity:unique=0:partial=0:namespace,canonical_id,client_id,client_sequence", + ), + "store6_mutation_execution" to + setOf( + "store6_mutation_execution_phase:unique=0:partial=0:client_id,phase,client_sequence", + ), + "store6_mutation_failure" to + setOf( + "store6_mutation_failure_prune:unique=0:partial=0:client_id,client_sequence,failure_id", + "store6_mutation_failure_order:unique=0:partial=0:client_id,failure_id", + ), + "store6_key_tombstone" to + setOf( + "store6_key_tombstone_state:unique=0:partial=0:namespace,canonical_id,state", + "store6_key_tombstone_one_pending:unique=1:partial=1:namespace,canonical_id", + "store6_key_tombstone_one_active:unique=1:partial=1:namespace,canonical_id", + ), + ) + } +} diff --git a/store6-mutations-sqldelight/src/jvmTest/kotlin/JournalStorageInstallDocsSnippet.kt b/store6-mutations-sqldelight/src/jvmTest/kotlin/JournalStorageInstallDocsSnippet.kt new file mode 100644 index 000000000..27ee9c297 --- /dev/null +++ b/store6-mutations-sqldelight/src/jvmTest/kotlin/JournalStorageInstallDocsSnippet.kt @@ -0,0 +1,69 @@ +// docs:snippet:mutations-journal-sqldelight-install +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +import org.mobilenativefoundation.store6.mutations.mutationStore +import org.mobilenativefoundation.store6.mutations.sqldelight.SqlDelightMutationJournalStorage + +val journal = + SqlDelightMutationJournalStorage( + driver = driver, + transacter = database, + ) + +val store = + mutationStore( + registry = registry, + server = server, + keyResolver = keyResolver, + valueCodecVersion = 1, + valueCodec = valueCodec, + ) { + fetcher(networkFetcher) + journalStorage(journal) + } +// docs:snippet:end + +class JournalStorageDocsKey( + private val id: String, +) : org.mobilenativefoundation.store6.core.StoreKey { + override val namespace: org.mobilenativefoundation.store6.core.StoreNamespace = + org.mobilenativefoundation.store6.core.StoreNamespace("docs-journal") + + override fun canonicalId(): String = id +} + +data class JournalStorageDocsValue( + val value: String, +) + +private val driver: app.cash.sqldelight.db.SqlDriver + get() = error("Compile-only documentation fixture") + +private val database: app.cash.sqldelight.Transacter + get() = error("Compile-only documentation fixture") + +private val registry: + org.mobilenativefoundation.store6.mutations.MutatorRegistry< + JournalStorageDocsKey, + JournalStorageDocsValue, + > + get() = error("Compile-only documentation fixture") + +private val server: + org.mobilenativefoundation.store6.mutations.MutationServer< + JournalStorageDocsKey, + JournalStorageDocsValue, + > + get() = error("Compile-only documentation fixture") + +private val keyResolver: + org.mobilenativefoundation.store6.mutations.MutationKeyResolver + get() = error("Compile-only documentation fixture") + +private val valueCodec: + org.mobilenativefoundation.store6.mutations.MutationCodec + get() = error("Compile-only documentation fixture") + +private val networkFetcher: + org.mobilenativefoundation.store6.core.seam.Fetcher + get() = error("Compile-only documentation fixture") diff --git a/store6-mutations-sqldelight/src/jvmTest/kotlin/TestingJournalContractDocsSnippet.kt b/store6-mutations-sqldelight/src/jvmTest/kotlin/TestingJournalContractDocsSnippet.kt new file mode 100644 index 000000000..3e7848858 --- /dev/null +++ b/store6-mutations-sqldelight/src/jvmTest/kotlin/TestingJournalContractDocsSnippet.kt @@ -0,0 +1,60 @@ +// docs:snippet:mutations-testing-journal-contract +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +import app.cash.sqldelight.Transacter +import app.cash.sqldelight.db.SqlDriver +import org.mobilenativefoundation.store6.mutations.sqldelight.SqlDelightMutationJournalStorage +import org.mobilenativefoundation.store6.mutations.storage.MutationJournalStorage +import org.mobilenativefoundation.store6.mutations.testing.MutationJournalStorageContractKit +import kotlin.test.AfterTest + +private class JournalFixture( + val driver: SqlDriver, + val transacter: Transacter, +) { + fun storage(): MutationJournalStorage = + SqlDelightMutationJournalStorage(driver, transacter) +} + +class SqlDelightJournalContractTest : MutationJournalStorageContractKit() { + private val fixtures = mutableListOf() + private val ownerByStorage = mutableMapOf() + + override fun createStorage(): MutationJournalStorage { + val fixture = freshJournalFixture() + fixtures += fixture + return fixture.storage().also { storage -> ownerByStorage[storage] = fixture } + } + + override fun reopenStorage(previous: MutationJournalStorage): MutationJournalStorage { + val fixture = checkNotNull(ownerByStorage[previous]) + return fixture.storage().also { storage -> ownerByStorage[storage] = fixture } + } + + @AfterTest + fun closeDrivers() { + var firstFailure: Throwable? = null + try { + fixtures.forEach { fixture -> + try { + fixture.driver.close() + } catch (failure: Throwable) { + if (firstFailure == null) firstFailure = failure + } + } + } finally { + fixtures.clear() + ownerByStorage.clear() + } + firstFailure?.let { throw it } + } +} +// docs:snippet:end + +private fun freshJournalFixture(): JournalFixture { + val harness = org.mobilenativefoundation.store6.mutations.sqldelight.freshJournalHarness() + return JournalFixture( + driver = harness.driver, + transacter = harness.transacter, + ) +} diff --git a/store6-mutations-sqldelight/src/jvmTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/FileBackedJournalReopenTest.kt b/store6-mutations-sqldelight/src/jvmTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/FileBackedJournalReopenTest.kt new file mode 100644 index 000000000..997d5aa1d --- /dev/null +++ b/store6-mutations-sqldelight/src/jvmTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/FileBackedJournalReopenTest.kt @@ -0,0 +1,145 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.mutations.sqldelight + +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import java.nio.file.Files +import java.nio.file.Path +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.mutations.MutationAck +import org.mobilenativefoundation.store6.mutations.MutationCodec +import org.mobilenativefoundation.store6.mutations.MutationKeyIdentity +import org.mobilenativefoundation.store6.mutations.MutationPresentAck +import org.mobilenativefoundation.store6.mutations.MutationPush +import org.mobilenativefoundation.store6.mutations.MutationRetirement +import org.mobilenativefoundation.store6.mutations.MutationRetirementAck +import org.mobilenativefoundation.store6.mutations.MutationServer +import org.mobilenativefoundation.store6.mutations.MutationStore +import org.mobilenativefoundation.store6.mutations.MutationPresence +import org.mobilenativefoundation.store6.mutations.MutatorRef +import org.mobilenativefoundation.store6.mutations.StaleSet +import org.mobilenativefoundation.store6.mutations.mutationStore +import org.mobilenativefoundation.store6.mutations.mutatorRegistry +import kotlin.test.Test +import kotlin.test.assertEquals + +internal class FileBackedJournalReopenTest { + @Test + fun pendingIntentHydratesAfterDriverAndStoreReopen() = runTest { + val directory = Files.createTempDirectory("store6-mutation-journal-") + val database = directory.resolve("journal.db") + var driverOne: JdbcSqliteDriver? = null + var driverTwo: JdbcSqliteDriver? = null + var storeOne: MutationStore? = null + var storeTwo: MutationStore? = null + try { + val firstDriver = fileDriver(database) + driverOne = firstDriver + val firstHarness = JournalHarness(firstDriver) + val fixture = fileFixture() + val firstStore = openStore(firstHarness, fixture) + storeOne = firstStore + firstStore.mutate(FileKey("a"), fixture.ref, "+mine") + firstStore.close() + storeOne = null + firstDriver.close() + driverOne = null + + val secondDriver = fileDriver(database) + driverTwo = secondDriver + val secondHarness = JournalHarness(secondDriver) + val secondStore = openStore(secondHarness, fixture) + storeTwo = secondStore + assertEquals( + listOf("mutation-1"), + secondStore.pending(FileKey("a")).map { pending -> pending.mutationId }, + ) + } finally { + var firstFailure: Throwable? = null + fun cleanup(block: () -> Unit) { + try { + block() + } catch (failure: Throwable) { + if (firstFailure == null) firstFailure = failure + } + } + cleanup { storeOne?.close() } + cleanup { storeTwo?.close() } + cleanup { driverOne?.close() } + cleanup { driverTwo?.close() } + cleanup { deleteDatabaseFiles(database) } + cleanup { Files.deleteIfExists(directory) } + firstFailure?.let { throw it } + } + } + + private fun fileDriver(database: Path): JdbcSqliteDriver = + JdbcSqliteDriver("jdbc:sqlite:${database.toAbsolutePath()}") + + private fun openStore( + harness: JournalHarness, + fixture: FileFixture, + ): MutationStore = + mutationStore( + registry = fixture.registry, + server = FileServer, + keyResolver = { identity: MutationKeyIdentity -> FileKey(identity.canonicalId) }, + valueCodecVersion = 1, + valueCodec = StringCodec, + ) { + fetcher { "confirmed" } + journalStorage(harness.storage()) + } + + private fun fileFixture(): FileFixture { + lateinit var ref: MutatorRef + val registry = + mutatorRegistry { + ref = + upsert( + id = "upsert", + version = 1, + codec = StringCodec, + stales = { _, _ -> StaleSet(emptySet(), emptySet()) }, + ) { base, suffix -> + val previous = (base as? MutationPresence.Present)?.value.orEmpty() + MutationPresence.Present(previous + suffix) + } + } + return FileFixture(registry, ref) + } + + private fun deleteDatabaseFiles(database: Path) { + Files.deleteIfExists(Path.of("$database-wal")) + Files.deleteIfExists(Path.of("$database-shm")) + Files.deleteIfExists(Path.of("$database-journal")) + Files.deleteIfExists(database) + } +} + +private class FileKey(private val id: String) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("items") + + override fun canonicalId(): String = id +} + +private object StringCodec : MutationCodec { + override fun encode(value: String): ByteArray = value.encodeToByteArray() + + override fun decode(version: Int, bytes: ByteArray): String = bytes.decodeToString() +} + +private object FileServer : MutationServer { + override suspend fun push(request: MutationPush): MutationAck = + MutationPresentAck("unused", null, null) + + override suspend fun retire(request: MutationRetirement): MutationRetirementAck = + MutationRetirementAck(request.retiredThroughSequence) +} + +private class FileFixture( + val registry: org.mobilenativefoundation.store6.mutations.MutatorRegistry, + val ref: MutatorRef, +) diff --git a/store6-mutations-sqldelight/src/jvmTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/JournalHarness.jvm.kt b/store6-mutations-sqldelight/src/jvmTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/JournalHarness.jvm.kt new file mode 100644 index 000000000..15f71ad0d --- /dev/null +++ b/store6-mutations-sqldelight/src/jvmTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/JournalHarness.jvm.kt @@ -0,0 +1,6 @@ +package org.mobilenativefoundation.store6.mutations.sqldelight + +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver + +internal actual fun freshJournalHarness(): JournalHarness = + JournalHarness(JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)) diff --git a/store6-mutations-sqldelight/src/jvmTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/Utf16JournalStorageTest.kt b/store6-mutations-sqldelight/src/jvmTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/Utf16JournalStorageTest.kt new file mode 100644 index 000000000..bfc2db189 --- /dev/null +++ b/store6-mutations-sqldelight/src/jvmTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/Utf16JournalStorageTest.kt @@ -0,0 +1,86 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.mutations.sqldelight + +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.mutations.MutationFailureKind +import org.mobilenativefoundation.store6.mutations.storage.MutationClientRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionPhase +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionRecord +import kotlin.test.Test +import kotlin.test.assertEquals + +internal class Utf16JournalStorageTest { + @Test + fun validUtf8FailureBoundsRemainValidInUtf16Database() = runTest { + val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + try { + val harness = JournalHarness(driver) + harness.executeRaw("PRAGMA encoding = 'UTF-16'") + val storage = harness.storage() + storage.transaction { transaction -> + transaction.insertClient( + MutationClientRecord( + recordVersion = 1, + clientId = "client", + lastAllocatedSequence = 0L, + retiredThroughSequence = 0L, + serverConfirmedRetiredThroughSequence = 0L, + createdAt = 1L, + ), + ) + transaction.advanceClient( + MutationClientRecord( + recordVersion = 1, + clientId = "client", + lastAllocatedSequence = 1L, + retiredThroughSequence = 0L, + serverConfirmedRetiredThroughSequence = 0L, + createdAt = 1L, + ), + ) + transaction.insertIntent( + recordVersion = 1, + clientId = "client", + clientSequence = 1L, + mutationId = "mutation-1", + namespace = "items", + canonicalId = "one", + mutatorId = "upsert", + mutatorVersion = 1, + argsBlob = byteArrayOf(1), + idempotencyRoot = "client:1", + createdAt = 2L, + ) + transaction.insertExecution( + MutationExecutionRecord( + clientId = "client", + clientSequence = 1L, + phase = MutationExecutionPhase.UNPREPARED, + currentGeneration = 0, + attempt = 0, + lastAttemptAt = null, + activeFailureId = null, + retiredAt = null, + ), + ) + transaction.appendFailure( + clientId = "client", + clientSequence = 1L, + generation = 0, + kind = MutationFailureKind.PERSISTENCE, + detail = "d".repeat(128), + message = "m".repeat(1_024), + occurredAt = 3L, + ) + } + + val failure = storage.transaction { it.failures("client").single() } + assertEquals(128, failure.detail.encodeToByteArray().size) + assertEquals(1_024, failure.message.encodeToByteArray().size) + } finally { + driver.close() + } + } +} diff --git a/store6-mutations-sqldelight/src/nativeSqlTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/JournalHarness.native.kt b/store6-mutations-sqldelight/src/nativeSqlTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/JournalHarness.native.kt new file mode 100644 index 000000000..7a1446442 --- /dev/null +++ b/store6-mutations-sqldelight/src/nativeSqlTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/JournalHarness.native.kt @@ -0,0 +1,6 @@ +package org.mobilenativefoundation.store6.mutations.sqldelight + +import app.cash.sqldelight.driver.native.inMemoryDriver + +internal actual fun freshJournalHarness(): JournalHarness = + JournalHarness(inMemoryDriver(JournalTestSchema)) diff --git a/store6-mutations-testing/api/android/store6-mutations-testing.api b/store6-mutations-testing/api/android/store6-mutations-testing.api new file mode 100644 index 000000000..99726b440 --- /dev/null +++ b/store6-mutations-testing/api/android/store6-mutations-testing.api @@ -0,0 +1,96 @@ +public final class org/mobilenativefoundation/store6/mutations/testing/JournalStorageCrashException : java/lang/RuntimeException { + public fun (Lorg/mobilenativefoundation/store6/mutations/testing/JournalStorageKillPoint;)V + public final fun getKillPoint ()Lorg/mobilenativefoundation/store6/mutations/testing/JournalStorageKillPoint; +} + +public final class org/mobilenativefoundation/store6/mutations/testing/JournalStorageKillPoint : java/lang/Enum { + public static final field AFTER_PRUNE_COMMIT Lorg/mobilenativefoundation/store6/mutations/testing/JournalStorageKillPoint; + public static final field AFTER_RETIREMENT_FINALIZATION_COMMIT Lorg/mobilenativefoundation/store6/mutations/testing/JournalStorageKillPoint; + public static final field BEFORE_PRUNE Lorg/mobilenativefoundation/store6/mutations/testing/JournalStorageKillPoint; + public static final field BEFORE_PRUNE_COMMIT Lorg/mobilenativefoundation/store6/mutations/testing/JournalStorageKillPoint; + public static final field BEFORE_RETIREMENT_FINALIZATION_COMMIT Lorg/mobilenativefoundation/store6/mutations/testing/JournalStorageKillPoint; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/mobilenativefoundation/store6/mutations/testing/JournalStorageKillPoint; + public static fun values ()[Lorg/mobilenativefoundation/store6/mutations/testing/JournalStorageKillPoint; +} + +public abstract class org/mobilenativefoundation/store6/mutations/testing/JournalStorageKillPointScenarios { + public fun ()V + public final fun afterPruneCommit_commitsAndClears ()V + public final fun afterRetirementFinalizationCommit_commitsAndClears ()V + public final fun beforePruneCommit_rollsBackAndClears ()V + public final fun beforePrune_abortsBeforeDeleteAndClears ()V + public final fun beforeRetirementFinalizationCommit_rollsBackAndClears ()V + public abstract fun createStorage ()Lorg/mobilenativefoundation/store6/mutations/storage/MutationJournalStorage; + public abstract fun reopenStorage (Lorg/mobilenativefoundation/store6/mutations/storage/MutationJournalStorage;)Lorg/mobilenativefoundation/store6/mutations/storage/MutationJournalStorage; +} + +public final class org/mobilenativefoundation/store6/mutations/testing/KillPointJournalStorage : org/mobilenativefoundation/store6/mutations/storage/MutationJournalStorage { + public fun (Lorg/mobilenativefoundation/store6/mutations/storage/MutationJournalStorage;)V + public final fun arm (Lorg/mobilenativefoundation/store6/mutations/testing/JournalStorageKillPoint;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun transaction (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract class org/mobilenativefoundation/store6/mutations/testing/MutationJournalStorageContractKit : org/mobilenativefoundation/store6/mutations/testing/JournalStorageKillPointScenarios { + public fun ()V + public final fun ackReceipt_ackRoutingGenerationAndAckedCommitAtomically ()V + public final fun ack_isWriteOnceAndMismatchDoesNotOverwrite ()V + public final fun adoptionAdvance_commitsAtomically ()V + public final fun alias_equalDuplicateIsIdempotentAndActivationIsMonotonic ()V + public final fun alias_rejectsSelfEdgeRetargetAndCycle ()V + public final fun attemptGeneration_isImmutableAndAppendOnly ()V + public final fun blobs_areCopiedOnEntryAndDelivery ()V + public final fun clientRecord_enforcesThreeWayInvariantAndMonotonicConfirmation ()V + public abstract fun createStorage ()Lorg/mobilenativefoundation/store6/mutations/storage/MutationJournalStorage; + public final fun effect_startsPendingAndAdvancesDispositionForwardOnly ()V + public final fun enqueue_intentAndUnpreparedExecutionCommitAtomically ()V + public final fun execution_ackedRequiresMatchingAcknowledgement ()V + public final fun execution_preservesRuledLegalEdgesAndTerminalStates ()V + public final fun failure_isAppendOnlyAndUtf8BoundedAtCodePointBoundary ()V + public final fun intent_isImmutableUniqueAndOrderedByClientSequence ()V + public final fun parking_failurePhaseAndActiveFailureCommitAtomically ()V + public final fun persistedEnums_roundTripEveryStableName ()V + public final fun preparation_attemptEffectsAndReadyCommitAtomically ()V + public final fun prune_preservesAliasesAndActiveTombstoneGeneration ()V + public final fun prune_stopsAtServerConfirmedPrefix ()V + public abstract fun reopenStorage (Lorg/mobilenativefoundation/store6/mutations/storage/MutationJournalStorage;)Lorg/mobilenativefoundation/store6/mutations/storage/MutationJournalStorage; + public final fun reopen_roundTripsAliasAndTombstoneRecords ()V + public final fun reopen_roundTripsAttemptAndAckRecords ()V + public final fun reopen_roundTripsClientRecord ()V + public final fun reopen_roundTripsFailureAndEffectRecords ()V + public final fun reopen_roundTripsIntentAndExecutionRecords ()V + public final fun retirementFinalization_highWaterAndRoutingCommitAtomically ()V + public final fun serverWins_skipRetireAndPrefixCommitAtomically ()V + public final fun tombstone_allowsAtMostOnePendingAndOneActiveGeneration ()V + public final fun tombstone_generationAdvancesForwardOnly ()V +} + +public final class org/mobilenativefoundation/store6/mutations/testing/MutatorAmbientProbe { + public fun (Ljava/lang/String;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;)V + public final fun getName ()Ljava/lang/String; +} + +public abstract class org/mobilenativefoundation/store6/mutations/testing/MutatorPurityContractKit { + public fun ()V + public final fun ambientState_doesNotAffectProjection ()V + public abstract fun createSubject ()Lorg/mobilenativefoundation/store6/mutations/testing/MutatorPuritySubject; + public final fun doubleApplication_independentReplayTracesAreEquivalent ()V + public final fun invocationCount_doesNotAffectProjection ()V + public final fun repeatDeterminism_sameInputsProduceStructurallyEqualOutput ()V +} + +public final class org/mobilenativefoundation/store6/mutations/testing/MutatorPurityContractKitKt { + public static final fun mutatorPuritySubject (Ljava/lang/String;ILorg/mobilenativefoundation/store6/mutations/MutationCodec;Lkotlin/jvm/functions/Function2;Ljava/util/List;Lkotlin/jvm/functions/Function1;Ljava/util/List;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)Lorg/mobilenativefoundation/store6/mutations/testing/MutatorPuritySubject; + public static synthetic fun mutatorPuritySubject$default (Ljava/lang/String;ILorg/mobilenativefoundation/store6/mutations/MutationCodec;Lkotlin/jvm/functions/Function2;Ljava/util/List;Lkotlin/jvm/functions/Function1;Ljava/util/List;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lorg/mobilenativefoundation/store6/mutations/testing/MutatorPuritySubject; +} + +public final class org/mobilenativefoundation/store6/mutations/testing/MutatorPuritySample { + public fun (Ljava/lang/String;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;)V + public final fun getName ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/mutations/testing/MutatorPuritySubject { + public final fun getRef ()Lorg/mobilenativefoundation/store6/mutations/MutatorRef; + public final fun getRegistry ()Lorg/mobilenativefoundation/store6/mutations/MutatorRegistry; +} + diff --git a/store6-mutations-testing/api/jvm/store6-mutations-testing.api b/store6-mutations-testing/api/jvm/store6-mutations-testing.api new file mode 100644 index 000000000..99726b440 --- /dev/null +++ b/store6-mutations-testing/api/jvm/store6-mutations-testing.api @@ -0,0 +1,96 @@ +public final class org/mobilenativefoundation/store6/mutations/testing/JournalStorageCrashException : java/lang/RuntimeException { + public fun (Lorg/mobilenativefoundation/store6/mutations/testing/JournalStorageKillPoint;)V + public final fun getKillPoint ()Lorg/mobilenativefoundation/store6/mutations/testing/JournalStorageKillPoint; +} + +public final class org/mobilenativefoundation/store6/mutations/testing/JournalStorageKillPoint : java/lang/Enum { + public static final field AFTER_PRUNE_COMMIT Lorg/mobilenativefoundation/store6/mutations/testing/JournalStorageKillPoint; + public static final field AFTER_RETIREMENT_FINALIZATION_COMMIT Lorg/mobilenativefoundation/store6/mutations/testing/JournalStorageKillPoint; + public static final field BEFORE_PRUNE Lorg/mobilenativefoundation/store6/mutations/testing/JournalStorageKillPoint; + public static final field BEFORE_PRUNE_COMMIT Lorg/mobilenativefoundation/store6/mutations/testing/JournalStorageKillPoint; + public static final field BEFORE_RETIREMENT_FINALIZATION_COMMIT Lorg/mobilenativefoundation/store6/mutations/testing/JournalStorageKillPoint; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/mobilenativefoundation/store6/mutations/testing/JournalStorageKillPoint; + public static fun values ()[Lorg/mobilenativefoundation/store6/mutations/testing/JournalStorageKillPoint; +} + +public abstract class org/mobilenativefoundation/store6/mutations/testing/JournalStorageKillPointScenarios { + public fun ()V + public final fun afterPruneCommit_commitsAndClears ()V + public final fun afterRetirementFinalizationCommit_commitsAndClears ()V + public final fun beforePruneCommit_rollsBackAndClears ()V + public final fun beforePrune_abortsBeforeDeleteAndClears ()V + public final fun beforeRetirementFinalizationCommit_rollsBackAndClears ()V + public abstract fun createStorage ()Lorg/mobilenativefoundation/store6/mutations/storage/MutationJournalStorage; + public abstract fun reopenStorage (Lorg/mobilenativefoundation/store6/mutations/storage/MutationJournalStorage;)Lorg/mobilenativefoundation/store6/mutations/storage/MutationJournalStorage; +} + +public final class org/mobilenativefoundation/store6/mutations/testing/KillPointJournalStorage : org/mobilenativefoundation/store6/mutations/storage/MutationJournalStorage { + public fun (Lorg/mobilenativefoundation/store6/mutations/storage/MutationJournalStorage;)V + public final fun arm (Lorg/mobilenativefoundation/store6/mutations/testing/JournalStorageKillPoint;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun transaction (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract class org/mobilenativefoundation/store6/mutations/testing/MutationJournalStorageContractKit : org/mobilenativefoundation/store6/mutations/testing/JournalStorageKillPointScenarios { + public fun ()V + public final fun ackReceipt_ackRoutingGenerationAndAckedCommitAtomically ()V + public final fun ack_isWriteOnceAndMismatchDoesNotOverwrite ()V + public final fun adoptionAdvance_commitsAtomically ()V + public final fun alias_equalDuplicateIsIdempotentAndActivationIsMonotonic ()V + public final fun alias_rejectsSelfEdgeRetargetAndCycle ()V + public final fun attemptGeneration_isImmutableAndAppendOnly ()V + public final fun blobs_areCopiedOnEntryAndDelivery ()V + public final fun clientRecord_enforcesThreeWayInvariantAndMonotonicConfirmation ()V + public abstract fun createStorage ()Lorg/mobilenativefoundation/store6/mutations/storage/MutationJournalStorage; + public final fun effect_startsPendingAndAdvancesDispositionForwardOnly ()V + public final fun enqueue_intentAndUnpreparedExecutionCommitAtomically ()V + public final fun execution_ackedRequiresMatchingAcknowledgement ()V + public final fun execution_preservesRuledLegalEdgesAndTerminalStates ()V + public final fun failure_isAppendOnlyAndUtf8BoundedAtCodePointBoundary ()V + public final fun intent_isImmutableUniqueAndOrderedByClientSequence ()V + public final fun parking_failurePhaseAndActiveFailureCommitAtomically ()V + public final fun persistedEnums_roundTripEveryStableName ()V + public final fun preparation_attemptEffectsAndReadyCommitAtomically ()V + public final fun prune_preservesAliasesAndActiveTombstoneGeneration ()V + public final fun prune_stopsAtServerConfirmedPrefix ()V + public abstract fun reopenStorage (Lorg/mobilenativefoundation/store6/mutations/storage/MutationJournalStorage;)Lorg/mobilenativefoundation/store6/mutations/storage/MutationJournalStorage; + public final fun reopen_roundTripsAliasAndTombstoneRecords ()V + public final fun reopen_roundTripsAttemptAndAckRecords ()V + public final fun reopen_roundTripsClientRecord ()V + public final fun reopen_roundTripsFailureAndEffectRecords ()V + public final fun reopen_roundTripsIntentAndExecutionRecords ()V + public final fun retirementFinalization_highWaterAndRoutingCommitAtomically ()V + public final fun serverWins_skipRetireAndPrefixCommitAtomically ()V + public final fun tombstone_allowsAtMostOnePendingAndOneActiveGeneration ()V + public final fun tombstone_generationAdvancesForwardOnly ()V +} + +public final class org/mobilenativefoundation/store6/mutations/testing/MutatorAmbientProbe { + public fun (Ljava/lang/String;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;)V + public final fun getName ()Ljava/lang/String; +} + +public abstract class org/mobilenativefoundation/store6/mutations/testing/MutatorPurityContractKit { + public fun ()V + public final fun ambientState_doesNotAffectProjection ()V + public abstract fun createSubject ()Lorg/mobilenativefoundation/store6/mutations/testing/MutatorPuritySubject; + public final fun doubleApplication_independentReplayTracesAreEquivalent ()V + public final fun invocationCount_doesNotAffectProjection ()V + public final fun repeatDeterminism_sameInputsProduceStructurallyEqualOutput ()V +} + +public final class org/mobilenativefoundation/store6/mutations/testing/MutatorPurityContractKitKt { + public static final fun mutatorPuritySubject (Ljava/lang/String;ILorg/mobilenativefoundation/store6/mutations/MutationCodec;Lkotlin/jvm/functions/Function2;Ljava/util/List;Lkotlin/jvm/functions/Function1;Ljava/util/List;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)Lorg/mobilenativefoundation/store6/mutations/testing/MutatorPuritySubject; + public static synthetic fun mutatorPuritySubject$default (Ljava/lang/String;ILorg/mobilenativefoundation/store6/mutations/MutationCodec;Lkotlin/jvm/functions/Function2;Ljava/util/List;Lkotlin/jvm/functions/Function1;Ljava/util/List;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lorg/mobilenativefoundation/store6/mutations/testing/MutatorPuritySubject; +} + +public final class org/mobilenativefoundation/store6/mutations/testing/MutatorPuritySample { + public fun (Ljava/lang/String;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;)V + public final fun getName ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/mutations/testing/MutatorPuritySubject { + public final fun getRef ()Lorg/mobilenativefoundation/store6/mutations/MutatorRef; + public final fun getRegistry ()Lorg/mobilenativefoundation/store6/mutations/MutatorRegistry; +} + diff --git a/store6-mutations-testing/api/store6-mutations-testing.klib.api b/store6-mutations-testing/api/store6-mutations-testing.klib.api new file mode 100644 index 000000000..5a668e2a2 --- /dev/null +++ b/store6-mutations-testing/api/store6-mutations-testing.klib.api @@ -0,0 +1,380 @@ +// Klib ABI Dump +// Targets: [iosArm64, iosSimulatorArm64, iosX64, js, linuxX64, macosArm64, mingwX64, tvosArm64, wasmJs, watchosArm64] +// Alias: native => [iosArm64, iosSimulatorArm64, iosX64, linuxX64, macosArm64, mingwX64, tvosArm64, watchosArm64] +// Rendering settings: +// - Signature version: 2 +// - Show manifest properties: true +// - Show declarations: true + +// Library unique name: +final enum class org.mobilenativefoundation.store6.mutations.testing/JournalStorageKillPoint : kotlin/Enum { // org.mobilenativefoundation.store6.mutations.testing/JournalStorageKillPoint|null[0] + enum entry AFTER_PRUNE_COMMIT // org.mobilenativefoundation.store6.mutations.testing/JournalStorageKillPoint.AFTER_PRUNE_COMMIT|null[0] + enum entry AFTER_RETIREMENT_FINALIZATION_COMMIT // org.mobilenativefoundation.store6.mutations.testing/JournalStorageKillPoint.AFTER_RETIREMENT_FINALIZATION_COMMIT|null[0] + enum entry BEFORE_PRUNE // org.mobilenativefoundation.store6.mutations.testing/JournalStorageKillPoint.BEFORE_PRUNE|null[0] + enum entry BEFORE_PRUNE_COMMIT // org.mobilenativefoundation.store6.mutations.testing/JournalStorageKillPoint.BEFORE_PRUNE_COMMIT|null[0] + enum entry BEFORE_RETIREMENT_FINALIZATION_COMMIT // org.mobilenativefoundation.store6.mutations.testing/JournalStorageKillPoint.BEFORE_RETIREMENT_FINALIZATION_COMMIT|null[0] + + final val entries // org.mobilenativefoundation.store6.mutations.testing/JournalStorageKillPoint.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // org.mobilenativefoundation.store6.mutations.testing/JournalStorageKillPoint.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): org.mobilenativefoundation.store6.mutations.testing/JournalStorageKillPoint // org.mobilenativefoundation.store6.mutations.testing/JournalStorageKillPoint.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // org.mobilenativefoundation.store6.mutations.testing/JournalStorageKillPoint.values|values#static(){}[0] +} + +abstract class <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any, #C: kotlin/Any, #D: kotlin/Any> org.mobilenativefoundation.store6.mutations.testing/MutatorPurityContractKit { // org.mobilenativefoundation.store6.mutations.testing/MutatorPurityContractKit|null[0] + constructor () // org.mobilenativefoundation.store6.mutations.testing/MutatorPurityContractKit.|(){}[0] + + abstract fun createSubject(): org.mobilenativefoundation.store6.mutations.testing/MutatorPuritySubject<#A, #B, #C, #D> // org.mobilenativefoundation.store6.mutations.testing/MutatorPurityContractKit.createSubject|createSubject(){}[0] + final fun ambientState_doesNotAffectProjection() // org.mobilenativefoundation.store6.mutations.testing/MutatorPurityContractKit.ambientState_doesNotAffectProjection|ambientState_doesNotAffectProjection(){}[0] + final fun doubleApplication_independentReplayTracesAreEquivalent() // org.mobilenativefoundation.store6.mutations.testing/MutatorPurityContractKit.doubleApplication_independentReplayTracesAreEquivalent|doubleApplication_independentReplayTracesAreEquivalent(){}[0] + final fun invocationCount_doesNotAffectProjection() // org.mobilenativefoundation.store6.mutations.testing/MutatorPurityContractKit.invocationCount_doesNotAffectProjection|invocationCount_doesNotAffectProjection(){}[0] + final fun repeatDeterminism_sameInputsProduceStructurallyEqualOutput() // org.mobilenativefoundation.store6.mutations.testing/MutatorPurityContractKit.repeatDeterminism_sameInputsProduceStructurallyEqualOutput|repeatDeterminism_sameInputsProduceStructurallyEqualOutput(){}[0] +} + +abstract class org.mobilenativefoundation.store6.mutations.testing/JournalStorageKillPointScenarios { // org.mobilenativefoundation.store6.mutations.testing/JournalStorageKillPointScenarios|null[0] + constructor () // org.mobilenativefoundation.store6.mutations.testing/JournalStorageKillPointScenarios.|(){}[0] + + abstract fun createStorage(): org.mobilenativefoundation.store6.mutations.storage/MutationJournalStorage // org.mobilenativefoundation.store6.mutations.testing/JournalStorageKillPointScenarios.createStorage|createStorage(){}[0] + abstract fun reopenStorage(org.mobilenativefoundation.store6.mutations.storage/MutationJournalStorage): org.mobilenativefoundation.store6.mutations.storage/MutationJournalStorage // org.mobilenativefoundation.store6.mutations.testing/JournalStorageKillPointScenarios.reopenStorage|reopenStorage(org.mobilenativefoundation.store6.mutations.storage.MutationJournalStorage){}[0] + + // Targets: [native] + final fun afterPruneCommit_commitsAndClears() // org.mobilenativefoundation.store6.mutations.testing/JournalStorageKillPointScenarios.afterPruneCommit_commitsAndClears|afterPruneCommit_commitsAndClears(){}[0] + + // Targets: [native] + final fun afterRetirementFinalizationCommit_commitsAndClears() // org.mobilenativefoundation.store6.mutations.testing/JournalStorageKillPointScenarios.afterRetirementFinalizationCommit_commitsAndClears|afterRetirementFinalizationCommit_commitsAndClears(){}[0] + + // Targets: [native] + final fun beforePruneCommit_rollsBackAndClears() // org.mobilenativefoundation.store6.mutations.testing/JournalStorageKillPointScenarios.beforePruneCommit_rollsBackAndClears|beforePruneCommit_rollsBackAndClears(){}[0] + + // Targets: [native] + final fun beforePrune_abortsBeforeDeleteAndClears() // org.mobilenativefoundation.store6.mutations.testing/JournalStorageKillPointScenarios.beforePrune_abortsBeforeDeleteAndClears|beforePrune_abortsBeforeDeleteAndClears(){}[0] + + // Targets: [native] + final fun beforeRetirementFinalizationCommit_rollsBackAndClears() // org.mobilenativefoundation.store6.mutations.testing/JournalStorageKillPointScenarios.beforeRetirementFinalizationCommit_rollsBackAndClears|beforeRetirementFinalizationCommit_rollsBackAndClears(){}[0] + + // Targets: [js] + final fun afterPruneCommit_commitsAndClears(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/JournalStorageKillPointScenarios.afterPruneCommit_commitsAndClears|afterPruneCommit_commitsAndClears(){}[0] + + // Targets: [js] + final fun afterRetirementFinalizationCommit_commitsAndClears(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/JournalStorageKillPointScenarios.afterRetirementFinalizationCommit_commitsAndClears|afterRetirementFinalizationCommit_commitsAndClears(){}[0] + + // Targets: [js] + final fun beforePruneCommit_rollsBackAndClears(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/JournalStorageKillPointScenarios.beforePruneCommit_rollsBackAndClears|beforePruneCommit_rollsBackAndClears(){}[0] + + // Targets: [js] + final fun beforePrune_abortsBeforeDeleteAndClears(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/JournalStorageKillPointScenarios.beforePrune_abortsBeforeDeleteAndClears|beforePrune_abortsBeforeDeleteAndClears(){}[0] + + // Targets: [js] + final fun beforeRetirementFinalizationCommit_rollsBackAndClears(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/JournalStorageKillPointScenarios.beforeRetirementFinalizationCommit_rollsBackAndClears|beforeRetirementFinalizationCommit_rollsBackAndClears(){}[0] + + // Targets: [wasmJs] + final fun afterPruneCommit_commitsAndClears(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/JournalStorageKillPointScenarios.afterPruneCommit_commitsAndClears|afterPruneCommit_commitsAndClears(){}[0] + + // Targets: [wasmJs] + final fun afterRetirementFinalizationCommit_commitsAndClears(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/JournalStorageKillPointScenarios.afterRetirementFinalizationCommit_commitsAndClears|afterRetirementFinalizationCommit_commitsAndClears(){}[0] + + // Targets: [wasmJs] + final fun beforePruneCommit_rollsBackAndClears(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/JournalStorageKillPointScenarios.beforePruneCommit_rollsBackAndClears|beforePruneCommit_rollsBackAndClears(){}[0] + + // Targets: [wasmJs] + final fun beforePrune_abortsBeforeDeleteAndClears(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/JournalStorageKillPointScenarios.beforePrune_abortsBeforeDeleteAndClears|beforePrune_abortsBeforeDeleteAndClears(){}[0] + + // Targets: [wasmJs] + final fun beforeRetirementFinalizationCommit_rollsBackAndClears(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/JournalStorageKillPointScenarios.beforeRetirementFinalizationCommit_rollsBackAndClears|beforeRetirementFinalizationCommit_rollsBackAndClears(){}[0] +} + +abstract class org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit : org.mobilenativefoundation.store6.mutations.testing/JournalStorageKillPointScenarios { // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit|null[0] + constructor () // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.|(){}[0] + + abstract fun createStorage(): org.mobilenativefoundation.store6.mutations.storage/MutationJournalStorage // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.createStorage|createStorage(){}[0] + abstract fun reopenStorage(org.mobilenativefoundation.store6.mutations.storage/MutationJournalStorage): org.mobilenativefoundation.store6.mutations.storage/MutationJournalStorage // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.reopenStorage|reopenStorage(org.mobilenativefoundation.store6.mutations.storage.MutationJournalStorage){}[0] + + // Targets: [native] + final fun ackReceipt_ackRoutingGenerationAndAckedCommitAtomically() // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.ackReceipt_ackRoutingGenerationAndAckedCommitAtomically|ackReceipt_ackRoutingGenerationAndAckedCommitAtomically(){}[0] + + // Targets: [native] + final fun ack_isWriteOnceAndMismatchDoesNotOverwrite() // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.ack_isWriteOnceAndMismatchDoesNotOverwrite|ack_isWriteOnceAndMismatchDoesNotOverwrite(){}[0] + + // Targets: [native] + final fun adoptionAdvance_commitsAtomically() // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.adoptionAdvance_commitsAtomically|adoptionAdvance_commitsAtomically(){}[0] + + // Targets: [native] + final fun alias_equalDuplicateIsIdempotentAndActivationIsMonotonic() // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.alias_equalDuplicateIsIdempotentAndActivationIsMonotonic|alias_equalDuplicateIsIdempotentAndActivationIsMonotonic(){}[0] + + // Targets: [native] + final fun alias_rejectsSelfEdgeRetargetAndCycle() // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.alias_rejectsSelfEdgeRetargetAndCycle|alias_rejectsSelfEdgeRetargetAndCycle(){}[0] + + // Targets: [native] + final fun attemptGeneration_isImmutableAndAppendOnly() // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.attemptGeneration_isImmutableAndAppendOnly|attemptGeneration_isImmutableAndAppendOnly(){}[0] + + // Targets: [native] + final fun blobs_areCopiedOnEntryAndDelivery() // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.blobs_areCopiedOnEntryAndDelivery|blobs_areCopiedOnEntryAndDelivery(){}[0] + + // Targets: [native] + final fun clientRecord_enforcesThreeWayInvariantAndMonotonicConfirmation() // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.clientRecord_enforcesThreeWayInvariantAndMonotonicConfirmation|clientRecord_enforcesThreeWayInvariantAndMonotonicConfirmation(){}[0] + + // Targets: [native] + final fun effect_startsPendingAndAdvancesDispositionForwardOnly() // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.effect_startsPendingAndAdvancesDispositionForwardOnly|effect_startsPendingAndAdvancesDispositionForwardOnly(){}[0] + + // Targets: [native] + final fun enqueue_intentAndUnpreparedExecutionCommitAtomically() // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.enqueue_intentAndUnpreparedExecutionCommitAtomically|enqueue_intentAndUnpreparedExecutionCommitAtomically(){}[0] + + // Targets: [native] + final fun execution_ackedRequiresMatchingAcknowledgement() // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.execution_ackedRequiresMatchingAcknowledgement|execution_ackedRequiresMatchingAcknowledgement(){}[0] + + // Targets: [native] + final fun execution_preservesRuledLegalEdgesAndTerminalStates() // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.execution_preservesRuledLegalEdgesAndTerminalStates|execution_preservesRuledLegalEdgesAndTerminalStates(){}[0] + + // Targets: [native] + final fun failure_isAppendOnlyAndUtf8BoundedAtCodePointBoundary() // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.failure_isAppendOnlyAndUtf8BoundedAtCodePointBoundary|failure_isAppendOnlyAndUtf8BoundedAtCodePointBoundary(){}[0] + + // Targets: [native] + final fun intent_isImmutableUniqueAndOrderedByClientSequence() // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.intent_isImmutableUniqueAndOrderedByClientSequence|intent_isImmutableUniqueAndOrderedByClientSequence(){}[0] + + // Targets: [native] + final fun parking_failurePhaseAndActiveFailureCommitAtomically() // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.parking_failurePhaseAndActiveFailureCommitAtomically|parking_failurePhaseAndActiveFailureCommitAtomically(){}[0] + + // Targets: [native] + final fun persistedEnums_roundTripEveryStableName() // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.persistedEnums_roundTripEveryStableName|persistedEnums_roundTripEveryStableName(){}[0] + + // Targets: [native] + final fun preparation_attemptEffectsAndReadyCommitAtomically() // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.preparation_attemptEffectsAndReadyCommitAtomically|preparation_attemptEffectsAndReadyCommitAtomically(){}[0] + + // Targets: [native] + final fun prune_preservesAliasesAndActiveTombstoneGeneration() // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.prune_preservesAliasesAndActiveTombstoneGeneration|prune_preservesAliasesAndActiveTombstoneGeneration(){}[0] + + // Targets: [native] + final fun prune_stopsAtServerConfirmedPrefix() // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.prune_stopsAtServerConfirmedPrefix|prune_stopsAtServerConfirmedPrefix(){}[0] + + // Targets: [native] + final fun reopen_roundTripsAliasAndTombstoneRecords() // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.reopen_roundTripsAliasAndTombstoneRecords|reopen_roundTripsAliasAndTombstoneRecords(){}[0] + + // Targets: [native] + final fun reopen_roundTripsAttemptAndAckRecords() // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.reopen_roundTripsAttemptAndAckRecords|reopen_roundTripsAttemptAndAckRecords(){}[0] + + // Targets: [native] + final fun reopen_roundTripsClientRecord() // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.reopen_roundTripsClientRecord|reopen_roundTripsClientRecord(){}[0] + + // Targets: [native] + final fun reopen_roundTripsFailureAndEffectRecords() // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.reopen_roundTripsFailureAndEffectRecords|reopen_roundTripsFailureAndEffectRecords(){}[0] + + // Targets: [native] + final fun reopen_roundTripsIntentAndExecutionRecords() // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.reopen_roundTripsIntentAndExecutionRecords|reopen_roundTripsIntentAndExecutionRecords(){}[0] + + // Targets: [native] + final fun retirementFinalization_highWaterAndRoutingCommitAtomically() // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.retirementFinalization_highWaterAndRoutingCommitAtomically|retirementFinalization_highWaterAndRoutingCommitAtomically(){}[0] + + // Targets: [native] + final fun serverWins_skipRetireAndPrefixCommitAtomically() // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.serverWins_skipRetireAndPrefixCommitAtomically|serverWins_skipRetireAndPrefixCommitAtomically(){}[0] + + // Targets: [native] + final fun tombstone_allowsAtMostOnePendingAndOneActiveGeneration() // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.tombstone_allowsAtMostOnePendingAndOneActiveGeneration|tombstone_allowsAtMostOnePendingAndOneActiveGeneration(){}[0] + + // Targets: [native] + final fun tombstone_generationAdvancesForwardOnly() // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.tombstone_generationAdvancesForwardOnly|tombstone_generationAdvancesForwardOnly(){}[0] + + // Targets: [js] + final fun ackReceipt_ackRoutingGenerationAndAckedCommitAtomically(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.ackReceipt_ackRoutingGenerationAndAckedCommitAtomically|ackReceipt_ackRoutingGenerationAndAckedCommitAtomically(){}[0] + + // Targets: [js] + final fun ack_isWriteOnceAndMismatchDoesNotOverwrite(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.ack_isWriteOnceAndMismatchDoesNotOverwrite|ack_isWriteOnceAndMismatchDoesNotOverwrite(){}[0] + + // Targets: [js] + final fun adoptionAdvance_commitsAtomically(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.adoptionAdvance_commitsAtomically|adoptionAdvance_commitsAtomically(){}[0] + + // Targets: [js] + final fun alias_equalDuplicateIsIdempotentAndActivationIsMonotonic(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.alias_equalDuplicateIsIdempotentAndActivationIsMonotonic|alias_equalDuplicateIsIdempotentAndActivationIsMonotonic(){}[0] + + // Targets: [js] + final fun alias_rejectsSelfEdgeRetargetAndCycle(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.alias_rejectsSelfEdgeRetargetAndCycle|alias_rejectsSelfEdgeRetargetAndCycle(){}[0] + + // Targets: [js] + final fun attemptGeneration_isImmutableAndAppendOnly(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.attemptGeneration_isImmutableAndAppendOnly|attemptGeneration_isImmutableAndAppendOnly(){}[0] + + // Targets: [js] + final fun blobs_areCopiedOnEntryAndDelivery(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.blobs_areCopiedOnEntryAndDelivery|blobs_areCopiedOnEntryAndDelivery(){}[0] + + // Targets: [js] + final fun clientRecord_enforcesThreeWayInvariantAndMonotonicConfirmation(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.clientRecord_enforcesThreeWayInvariantAndMonotonicConfirmation|clientRecord_enforcesThreeWayInvariantAndMonotonicConfirmation(){}[0] + + // Targets: [js] + final fun effect_startsPendingAndAdvancesDispositionForwardOnly(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.effect_startsPendingAndAdvancesDispositionForwardOnly|effect_startsPendingAndAdvancesDispositionForwardOnly(){}[0] + + // Targets: [js] + final fun enqueue_intentAndUnpreparedExecutionCommitAtomically(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.enqueue_intentAndUnpreparedExecutionCommitAtomically|enqueue_intentAndUnpreparedExecutionCommitAtomically(){}[0] + + // Targets: [js] + final fun execution_ackedRequiresMatchingAcknowledgement(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.execution_ackedRequiresMatchingAcknowledgement|execution_ackedRequiresMatchingAcknowledgement(){}[0] + + // Targets: [js] + final fun execution_preservesRuledLegalEdgesAndTerminalStates(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.execution_preservesRuledLegalEdgesAndTerminalStates|execution_preservesRuledLegalEdgesAndTerminalStates(){}[0] + + // Targets: [js] + final fun failure_isAppendOnlyAndUtf8BoundedAtCodePointBoundary(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.failure_isAppendOnlyAndUtf8BoundedAtCodePointBoundary|failure_isAppendOnlyAndUtf8BoundedAtCodePointBoundary(){}[0] + + // Targets: [js] + final fun intent_isImmutableUniqueAndOrderedByClientSequence(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.intent_isImmutableUniqueAndOrderedByClientSequence|intent_isImmutableUniqueAndOrderedByClientSequence(){}[0] + + // Targets: [js] + final fun parking_failurePhaseAndActiveFailureCommitAtomically(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.parking_failurePhaseAndActiveFailureCommitAtomically|parking_failurePhaseAndActiveFailureCommitAtomically(){}[0] + + // Targets: [js] + final fun persistedEnums_roundTripEveryStableName(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.persistedEnums_roundTripEveryStableName|persistedEnums_roundTripEveryStableName(){}[0] + + // Targets: [js] + final fun preparation_attemptEffectsAndReadyCommitAtomically(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.preparation_attemptEffectsAndReadyCommitAtomically|preparation_attemptEffectsAndReadyCommitAtomically(){}[0] + + // Targets: [js] + final fun prune_preservesAliasesAndActiveTombstoneGeneration(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.prune_preservesAliasesAndActiveTombstoneGeneration|prune_preservesAliasesAndActiveTombstoneGeneration(){}[0] + + // Targets: [js] + final fun prune_stopsAtServerConfirmedPrefix(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.prune_stopsAtServerConfirmedPrefix|prune_stopsAtServerConfirmedPrefix(){}[0] + + // Targets: [js] + final fun reopen_roundTripsAliasAndTombstoneRecords(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.reopen_roundTripsAliasAndTombstoneRecords|reopen_roundTripsAliasAndTombstoneRecords(){}[0] + + // Targets: [js] + final fun reopen_roundTripsAttemptAndAckRecords(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.reopen_roundTripsAttemptAndAckRecords|reopen_roundTripsAttemptAndAckRecords(){}[0] + + // Targets: [js] + final fun reopen_roundTripsClientRecord(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.reopen_roundTripsClientRecord|reopen_roundTripsClientRecord(){}[0] + + // Targets: [js] + final fun reopen_roundTripsFailureAndEffectRecords(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.reopen_roundTripsFailureAndEffectRecords|reopen_roundTripsFailureAndEffectRecords(){}[0] + + // Targets: [js] + final fun reopen_roundTripsIntentAndExecutionRecords(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.reopen_roundTripsIntentAndExecutionRecords|reopen_roundTripsIntentAndExecutionRecords(){}[0] + + // Targets: [js] + final fun retirementFinalization_highWaterAndRoutingCommitAtomically(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.retirementFinalization_highWaterAndRoutingCommitAtomically|retirementFinalization_highWaterAndRoutingCommitAtomically(){}[0] + + // Targets: [js] + final fun serverWins_skipRetireAndPrefixCommitAtomically(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.serverWins_skipRetireAndPrefixCommitAtomically|serverWins_skipRetireAndPrefixCommitAtomically(){}[0] + + // Targets: [js] + final fun tombstone_allowsAtMostOnePendingAndOneActiveGeneration(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.tombstone_allowsAtMostOnePendingAndOneActiveGeneration|tombstone_allowsAtMostOnePendingAndOneActiveGeneration(){}[0] + + // Targets: [js] + final fun tombstone_generationAdvancesForwardOnly(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.tombstone_generationAdvancesForwardOnly|tombstone_generationAdvancesForwardOnly(){}[0] + + // Targets: [wasmJs] + final fun ackReceipt_ackRoutingGenerationAndAckedCommitAtomically(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.ackReceipt_ackRoutingGenerationAndAckedCommitAtomically|ackReceipt_ackRoutingGenerationAndAckedCommitAtomically(){}[0] + + // Targets: [wasmJs] + final fun ack_isWriteOnceAndMismatchDoesNotOverwrite(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.ack_isWriteOnceAndMismatchDoesNotOverwrite|ack_isWriteOnceAndMismatchDoesNotOverwrite(){}[0] + + // Targets: [wasmJs] + final fun adoptionAdvance_commitsAtomically(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.adoptionAdvance_commitsAtomically|adoptionAdvance_commitsAtomically(){}[0] + + // Targets: [wasmJs] + final fun alias_equalDuplicateIsIdempotentAndActivationIsMonotonic(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.alias_equalDuplicateIsIdempotentAndActivationIsMonotonic|alias_equalDuplicateIsIdempotentAndActivationIsMonotonic(){}[0] + + // Targets: [wasmJs] + final fun alias_rejectsSelfEdgeRetargetAndCycle(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.alias_rejectsSelfEdgeRetargetAndCycle|alias_rejectsSelfEdgeRetargetAndCycle(){}[0] + + // Targets: [wasmJs] + final fun attemptGeneration_isImmutableAndAppendOnly(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.attemptGeneration_isImmutableAndAppendOnly|attemptGeneration_isImmutableAndAppendOnly(){}[0] + + // Targets: [wasmJs] + final fun blobs_areCopiedOnEntryAndDelivery(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.blobs_areCopiedOnEntryAndDelivery|blobs_areCopiedOnEntryAndDelivery(){}[0] + + // Targets: [wasmJs] + final fun clientRecord_enforcesThreeWayInvariantAndMonotonicConfirmation(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.clientRecord_enforcesThreeWayInvariantAndMonotonicConfirmation|clientRecord_enforcesThreeWayInvariantAndMonotonicConfirmation(){}[0] + + // Targets: [wasmJs] + final fun effect_startsPendingAndAdvancesDispositionForwardOnly(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.effect_startsPendingAndAdvancesDispositionForwardOnly|effect_startsPendingAndAdvancesDispositionForwardOnly(){}[0] + + // Targets: [wasmJs] + final fun enqueue_intentAndUnpreparedExecutionCommitAtomically(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.enqueue_intentAndUnpreparedExecutionCommitAtomically|enqueue_intentAndUnpreparedExecutionCommitAtomically(){}[0] + + // Targets: [wasmJs] + final fun execution_ackedRequiresMatchingAcknowledgement(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.execution_ackedRequiresMatchingAcknowledgement|execution_ackedRequiresMatchingAcknowledgement(){}[0] + + // Targets: [wasmJs] + final fun execution_preservesRuledLegalEdgesAndTerminalStates(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.execution_preservesRuledLegalEdgesAndTerminalStates|execution_preservesRuledLegalEdgesAndTerminalStates(){}[0] + + // Targets: [wasmJs] + final fun failure_isAppendOnlyAndUtf8BoundedAtCodePointBoundary(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.failure_isAppendOnlyAndUtf8BoundedAtCodePointBoundary|failure_isAppendOnlyAndUtf8BoundedAtCodePointBoundary(){}[0] + + // Targets: [wasmJs] + final fun intent_isImmutableUniqueAndOrderedByClientSequence(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.intent_isImmutableUniqueAndOrderedByClientSequence|intent_isImmutableUniqueAndOrderedByClientSequence(){}[0] + + // Targets: [wasmJs] + final fun parking_failurePhaseAndActiveFailureCommitAtomically(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.parking_failurePhaseAndActiveFailureCommitAtomically|parking_failurePhaseAndActiveFailureCommitAtomically(){}[0] + + // Targets: [wasmJs] + final fun persistedEnums_roundTripEveryStableName(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.persistedEnums_roundTripEveryStableName|persistedEnums_roundTripEveryStableName(){}[0] + + // Targets: [wasmJs] + final fun preparation_attemptEffectsAndReadyCommitAtomically(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.preparation_attemptEffectsAndReadyCommitAtomically|preparation_attemptEffectsAndReadyCommitAtomically(){}[0] + + // Targets: [wasmJs] + final fun prune_preservesAliasesAndActiveTombstoneGeneration(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.prune_preservesAliasesAndActiveTombstoneGeneration|prune_preservesAliasesAndActiveTombstoneGeneration(){}[0] + + // Targets: [wasmJs] + final fun prune_stopsAtServerConfirmedPrefix(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.prune_stopsAtServerConfirmedPrefix|prune_stopsAtServerConfirmedPrefix(){}[0] + + // Targets: [wasmJs] + final fun reopen_roundTripsAliasAndTombstoneRecords(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.reopen_roundTripsAliasAndTombstoneRecords|reopen_roundTripsAliasAndTombstoneRecords(){}[0] + + // Targets: [wasmJs] + final fun reopen_roundTripsAttemptAndAckRecords(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.reopen_roundTripsAttemptAndAckRecords|reopen_roundTripsAttemptAndAckRecords(){}[0] + + // Targets: [wasmJs] + final fun reopen_roundTripsClientRecord(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.reopen_roundTripsClientRecord|reopen_roundTripsClientRecord(){}[0] + + // Targets: [wasmJs] + final fun reopen_roundTripsFailureAndEffectRecords(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.reopen_roundTripsFailureAndEffectRecords|reopen_roundTripsFailureAndEffectRecords(){}[0] + + // Targets: [wasmJs] + final fun reopen_roundTripsIntentAndExecutionRecords(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.reopen_roundTripsIntentAndExecutionRecords|reopen_roundTripsIntentAndExecutionRecords(){}[0] + + // Targets: [wasmJs] + final fun retirementFinalization_highWaterAndRoutingCommitAtomically(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.retirementFinalization_highWaterAndRoutingCommitAtomically|retirementFinalization_highWaterAndRoutingCommitAtomically(){}[0] + + // Targets: [wasmJs] + final fun serverWins_skipRetireAndPrefixCommitAtomically(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.serverWins_skipRetireAndPrefixCommitAtomically|serverWins_skipRetireAndPrefixCommitAtomically(){}[0] + + // Targets: [wasmJs] + final fun tombstone_allowsAtMostOnePendingAndOneActiveGeneration(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.tombstone_allowsAtMostOnePendingAndOneActiveGeneration|tombstone_allowsAtMostOnePendingAndOneActiveGeneration(){}[0] + + // Targets: [wasmJs] + final fun tombstone_generationAdvancesForwardOnly(): kotlin.js/Promise // org.mobilenativefoundation.store6.mutations.testing/MutationJournalStorageContractKit.tombstone_generationAdvancesForwardOnly|tombstone_generationAdvancesForwardOnly(){}[0] +} + +final class <#A: kotlin/Any, #B: kotlin/Any> org.mobilenativefoundation.store6.mutations.testing/MutatorPuritySample { // org.mobilenativefoundation.store6.mutations.testing/MutatorPuritySample|null[0] + constructor (kotlin/String, kotlin/Function0>, kotlin/Function0<#B>) // org.mobilenativefoundation.store6.mutations.testing/MutatorPuritySample.|(kotlin.String;kotlin.Function0>;kotlin.Function0<1:1>){}[0] + + final val name // org.mobilenativefoundation.store6.mutations.testing/MutatorPuritySample.name|{}name[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations.testing/MutatorPuritySample.name.|(){}[0] +} + +final class <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any, #C: kotlin/Any, #D: kotlin/Any> org.mobilenativefoundation.store6.mutations.testing/MutatorPuritySubject { // org.mobilenativefoundation.store6.mutations.testing/MutatorPuritySubject|null[0] + final val ref // org.mobilenativefoundation.store6.mutations.testing/MutatorPuritySubject.ref|{}ref[0] + final fun (): org.mobilenativefoundation.store6.mutations/MutatorRef<#A, #B, #C> // org.mobilenativefoundation.store6.mutations.testing/MutatorPuritySubject.ref.|(){}[0] + final val registry // org.mobilenativefoundation.store6.mutations.testing/MutatorPuritySubject.registry|{}registry[0] + final fun (): org.mobilenativefoundation.store6.mutations/MutatorRegistry<#A, #B> // org.mobilenativefoundation.store6.mutations.testing/MutatorPuritySubject.registry.|(){}[0] +} + +final class org.mobilenativefoundation.store6.mutations.testing/JournalStorageCrashException : kotlin/RuntimeException { // org.mobilenativefoundation.store6.mutations.testing/JournalStorageCrashException|null[0] + constructor (org.mobilenativefoundation.store6.mutations.testing/JournalStorageKillPoint) // org.mobilenativefoundation.store6.mutations.testing/JournalStorageCrashException.|(org.mobilenativefoundation.store6.mutations.testing.JournalStorageKillPoint){}[0] + + final val killPoint // org.mobilenativefoundation.store6.mutations.testing/JournalStorageCrashException.killPoint|{}killPoint[0] + final fun (): org.mobilenativefoundation.store6.mutations.testing/JournalStorageKillPoint // org.mobilenativefoundation.store6.mutations.testing/JournalStorageCrashException.killPoint.|(){}[0] +} + +final class org.mobilenativefoundation.store6.mutations.testing/KillPointJournalStorage : org.mobilenativefoundation.store6.mutations.storage/MutationJournalStorage { // org.mobilenativefoundation.store6.mutations.testing/KillPointJournalStorage|null[0] + constructor (org.mobilenativefoundation.store6.mutations.storage/MutationJournalStorage) // org.mobilenativefoundation.store6.mutations.testing/KillPointJournalStorage.|(org.mobilenativefoundation.store6.mutations.storage.MutationJournalStorage){}[0] + + final suspend fun <#A1: kotlin/Any?> transaction(kotlin/Function1): #A1 // org.mobilenativefoundation.store6.mutations.testing/KillPointJournalStorage.transaction|transaction(kotlin.Function1){0§}[0] + final suspend fun arm(org.mobilenativefoundation.store6.mutations.testing/JournalStorageKillPoint) // org.mobilenativefoundation.store6.mutations.testing/KillPointJournalStorage.arm|arm(org.mobilenativefoundation.store6.mutations.testing.JournalStorageKillPoint){}[0] +} + +final class org.mobilenativefoundation.store6.mutations.testing/MutatorAmbientProbe { // org.mobilenativefoundation.store6.mutations.testing/MutatorAmbientProbe|null[0] + constructor (kotlin/String, kotlin/Function0, kotlin/Function0, kotlin/Function0) // org.mobilenativefoundation.store6.mutations.testing/MutatorAmbientProbe.|(kotlin.String;kotlin.Function0;kotlin.Function0;kotlin.Function0){}[0] + + final val name // org.mobilenativefoundation.store6.mutations.testing/MutatorAmbientProbe.name|{}name[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations.testing/MutatorAmbientProbe.name.|(){}[0] +} + +final fun <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any, #C: kotlin/Any, #D: kotlin/Any> org.mobilenativefoundation.store6.mutations.testing/mutatorPuritySubject(kotlin/String, kotlin/Int, org.mobilenativefoundation.store6.mutations/MutationCodec<#C>, kotlin/Function2<#A, #C, org.mobilenativefoundation.store6.mutations/StaleSet<#A>>, kotlin.collections/List>, kotlin/Function1<#B, #D>, kotlin.collections/List, kotlin/Function1, kotlin/Unit> = ..., kotlin/Function2, #C, org.mobilenativefoundation.store6.mutations/MutationPresence<#B>?>): org.mobilenativefoundation.store6.mutations.testing/MutatorPuritySubject<#A, #B, #C, #D> // org.mobilenativefoundation.store6.mutations.testing/mutatorPuritySubject|mutatorPuritySubject(kotlin.String;kotlin.Int;org.mobilenativefoundation.store6.mutations.MutationCodec<0:2>;kotlin.Function2<0:0,0:2,org.mobilenativefoundation.store6.mutations.StaleSet<0:0>>;kotlin.collections.List>;kotlin.Function1<0:1,0:3>;kotlin.collections.List;kotlin.Function1,kotlin.Unit>;kotlin.Function2,0:2,org.mobilenativefoundation.store6.mutations.MutationPresence<0:1>?>){0§;1§;2§;3§}[0] diff --git a/store6-mutations-testing/build.gradle.kts b/store6-mutations-testing/build.gradle.kts new file mode 100644 index 000000000..97b9aedf3 --- /dev/null +++ b/store6-mutations-testing/build.gradle.kts @@ -0,0 +1,29 @@ +plugins { + id("org.mobilenativefoundation.store.store6.multiplatform") +} + +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + api(projects.store6Mutations) + api(kotlin("test")) + api(libs.kotlinx.coroutines.test) + } + } + val jvmMain by getting { + dependencies { + api(kotlin("test-junit")) + } + } + val androidMain by getting { + dependencies { + api(kotlin("test-junit")) + } + } + } +} + +android { + namespace = "org.mobilenativefoundation.store6.mutations.testing" +} diff --git a/store6-mutations-testing/gradle.properties b/store6-mutations-testing/gradle.properties new file mode 100644 index 000000000..b415bfa5c --- /dev/null +++ b/store6-mutations-testing/gradle.properties @@ -0,0 +1,3 @@ +VERSION_NAME=6.0.0-SNAPSHOT +POM_NAME=store6-mutations-testing +POM_ARTIFACT_ID=store6-mutations-testing diff --git a/store6-mutations-testing/src/androidMain/AndroidManifest.xml b/store6-mutations-testing/src/androidMain/AndroidManifest.xml new file mode 100644 index 000000000..8072ee00d --- /dev/null +++ b/store6-mutations-testing/src/androidMain/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/store6-mutations-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/testing/JournalStorageKillPointScenarios.kt b/store6-mutations-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/testing/JournalStorageKillPointScenarios.kt new file mode 100644 index 000000000..e6010721c --- /dev/null +++ b/store6-mutations-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/testing/JournalStorageKillPointScenarios.kt @@ -0,0 +1,549 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.mutations.testing + +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.mutations.MutationFailureKind +import org.mobilenativefoundation.store6.mutations.MutationPresenceState +import org.mobilenativefoundation.store6.mutations.storage.MutationAckRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationAliasState +import org.mobilenativefoundation.store6.mutations.storage.MutationAttemptRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationClientRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationEffectDisposition +import org.mobilenativefoundation.store6.mutations.storage.MutationEffectKind +import org.mobilenativefoundation.store6.mutations.storage.MutationEffectRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionPhase +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationJournalStorage +import org.mobilenativefoundation.store6.mutations.storage.MutationJournalTransaction +import org.mobilenativefoundation.store6.mutations.storage.MutationKeyAliasRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationKeyTombstoneRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationTombstoneState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.time.Duration.Companion.seconds + +/** + * Inherited deterministic crash scenarios for every mutation-journal storage implementation. + * + * These scenarios classify semantic transaction shapes, never ordinal transaction counts or + * scheduler timing. [reopenStorage] is always called with the undecorated durable adapter so a + * persistent implementation can create a fresh adapter over the same database. + */ +@ExperimentalStoreApi +public abstract class JournalStorageKillPointScenarios { + /** Creates a fresh storage instance for one inherited scenario. */ + public abstract fun createStorage(): MutationJournalStorage + + /** Reopens [previous] without clearing its durable records. */ + public abstract fun reopenStorage(previous: MutationJournalStorage): MutationJournalStorage + + @Test + public fun beforeRetirementFinalizationCommit_rollsBackAndClears(): TestResult = + runKillPointTest { + val storage = createStorage() + seedEffectsPending(storage) + val killed = KillPointJournalStorage(storage) + killed.arm(JournalStorageKillPoint.BEFORE_RETIREMENT_FINALIZATION_COMMIT) + + val failure = + assertFailsWith { + finalizeRetirement(killed) + } + assertEquals(JournalStorageKillPoint.BEFORE_RETIREMENT_FINALIZATION_COMMIT, failure.killPoint) + + val reopened = reopenStorage(storage) + reopened.transaction { transaction -> + assertEquals( + MutationExecutionPhase.EFFECTS_PENDING, + transaction.executions(KILL_CLIENT_ID).single().phase, + ) + val effect = transaction.effects(KILL_CLIENT_ID).single() + assertEquals(MutationEffectDisposition.PENDING, effect.disposition) + assertNull(effect.completedAt) + assertEquals(0L, requireNotNull(transaction.client(KILL_CLIENT_ID)).retiredThroughSequence) + assertEquals(MutationAliasState.PENDING, transaction.aliases().single().state) + } + + // The kill point cleared before throwing; the same decorator can complete once. + finalizeRetirement(killed) + assertEquals( + MutationExecutionPhase.RETIRED, + storage.transaction { it.executions(KILL_CLIENT_ID).single().phase }, + ) + } + + @Test + public fun afterRetirementFinalizationCommit_commitsAndClears(): TestResult = + runKillPointTest { + val storage = createStorage() + seedEffectsPending(storage) + val killed = KillPointJournalStorage(storage) + killed.arm(JournalStorageKillPoint.AFTER_RETIREMENT_FINALIZATION_COMMIT) + + val failure = + assertFailsWith { + finalizeRetirement(killed) + } + assertEquals(JournalStorageKillPoint.AFTER_RETIREMENT_FINALIZATION_COMMIT, failure.killPoint) + + val reopened = reopenStorage(storage) + reopened.transaction { transaction -> + assertEquals( + MutationExecutionPhase.RETIRED, + transaction.executions(KILL_CLIENT_ID).single().phase, + ) + assertEquals( + MutationEffectDisposition.APPLIED, + transaction.effects(KILL_CLIENT_ID).single().disposition, + ) + assertEquals(1L, requireNotNull(transaction.client(KILL_CLIENT_ID)).retiredThroughSequence) + assertEquals(MutationAliasState.ACTIVE, transaction.aliases().single().state) + } + killed.arm(JournalStorageKillPoint.AFTER_RETIREMENT_FINALIZATION_COMMIT) + } + + /** + * Ordinary prune removes rows only at or below the persisted server-confirmed prefix; + * alias redirects and active or pending tombstone generations always survive. + */ + @Test + public fun beforePrune_abortsBeforeDeleteAndClears(): TestResult = + pruneKillPointScenario(JournalStorageKillPoint.BEFORE_PRUNE) + + /** The same prune bound, exercised at the rollback side of the prune commit boundary. */ + @Test + public fun beforePruneCommit_rollsBackAndClears(): TestResult = + pruneKillPointScenario(JournalStorageKillPoint.BEFORE_PRUNE_COMMIT) + + /** The same prune bound, exercised after the prune transaction has durably committed. */ + @Test + public fun afterPruneCommit_commitsAndClears(): TestResult = + pruneKillPointScenario(JournalStorageKillPoint.AFTER_PRUNE_COMMIT) + + private fun pruneKillPointScenario(point: JournalStorageKillPoint): TestResult = + runKillPointTest { + val rawStorage = createStorage() + seedPrunableRows(rawStorage) + val protected = rawStorage.transaction(::protectedSnapshot) + val killed = KillPointJournalStorage(rawStorage) + killed.arm(point) + + val failure = + assertFailsWith { + killed.transaction { transaction -> + transaction.prune(KILL_CLIENT_ID, serverConfirmedRetiredThroughSequence = 2L) + } + } + assertEquals(point, failure.killPoint) + + val reopened = reopenStorage(rawStorage) + assertEquals(protected, reopened.transaction(::protectedSnapshot), "after $point") + val retainedAfterCrash = reopened.transaction { it.intents(KILL_CLIENT_ID).map { row -> row.clientSequence } } + if (point == JournalStorageKillPoint.AFTER_PRUNE_COMMIT) { + assertEquals(listOf(3L, 4L), retainedAfterCrash) + } else { + assertEquals(listOf(1L, 2L, 3L, 4L), retainedAfterCrash) + } + + // The point cleared before throwing; retrying the same semantic operation is safe. + killed.transaction { transaction -> + transaction.prune(KILL_CLIENT_ID, serverConfirmedRetiredThroughSequence = 2L) + } + assertEquals(protected, rawStorage.transaction(::protectedSnapshot), "after retrying $point") + assertEquals( + listOf(3L, 4L), + rawStorage.transaction { it.intents(KILL_CLIENT_ID).map { row -> row.clientSequence } }, + ) + } +} + +private suspend fun seedEffectsPending(storage: MutationJournalStorage) { + storage.transaction { transaction -> + transaction.insertClient(killClient()) + transaction.advanceClient(killClient(lastAllocated = 1L)) + insertKillIntent(transaction, sequence = 1L) + transaction.insertExecution(killExecution(sequence = 1L)) + transaction.insertAttempt(killAttempt(sequence = 1L)) + transaction.insertEffect(killEffect(sequence = 1L)) + transaction.advanceExecution( + killExecution(sequence = 1L, phase = MutationExecutionPhase.READY, generation = 1), + ) + transaction.advanceExecution( + killExecution(sequence = 1L, phase = MutationExecutionPhase.INFLIGHT, generation = 1), + ) + transaction.insertAck(killAck(sequence = 1L)) + transaction.advanceExecution( + killExecution( + sequence = 1L, + phase = MutationExecutionPhase.ACKED, + generation = 1, + attempt = 1, + lastAttemptAt = 40L, + ), + ) + transaction.advanceExecution( + killExecution( + sequence = 1L, + phase = MutationExecutionPhase.EFFECTS_PENDING, + generation = 1, + attempt = 1, + lastAttemptAt = 40L, + ), + ) + transaction.insertAlias(killAlias()) + } +} + +private suspend fun finalizeRetirement(storage: MutationJournalStorage) { + storage.transaction { transaction -> + transaction.advanceEffect( + killEffect( + sequence = 1L, + disposition = MutationEffectDisposition.APPLIED, + completedAt = 50L, + ), + ) + transaction.advanceAlias(killAlias(state = MutationAliasState.ACTIVE, activatedAt = 50L)) + transaction.advanceExecution( + killExecution( + sequence = 1L, + phase = MutationExecutionPhase.RETIRED, + generation = 1, + attempt = 1, + lastAttemptAt = 40L, + retiredAt = 50L, + ), + ) + transaction.advanceClient(killClient(lastAllocated = 1L, retiredThrough = 1L)) + } +} + +private suspend fun seedPrunableRows(storage: MutationJournalStorage) { + storage.transaction { transaction -> + transaction.insertClient(killClient()) + transaction.advanceClient(killClient(lastAllocated = 4L)) + (1L..4L).forEach { sequence -> + insertKillIntent(transaction, sequence) + transaction.insertExecution(killExecution(sequence)) + } + } + (1L..4L).forEach { sequence -> + storage.transaction { transaction -> + transaction.insertAttempt(killAttempt(sequence)) + transaction.insertEffect(killEffect(sequence)) + transaction.advanceExecution( + killExecution(sequence, phase = MutationExecutionPhase.READY, generation = 1), + ) + transaction.advanceExecution( + killExecution(sequence, phase = MutationExecutionPhase.INFLIGHT, generation = 1), + ) + transaction.insertAck(killAck(sequence)) + transaction.appendFailure( + clientId = KILL_CLIENT_ID, + clientSequence = sequence, + generation = 1, + kind = MutationFailureKind.EFFECT, + detail = "retained-$sequence", + message = "retained evidence $sequence", + occurredAt = 40L + sequence, + ) + transaction.advanceExecution( + killExecution( + sequence, + phase = MutationExecutionPhase.ACKED, + generation = 1, + attempt = 1, + lastAttemptAt = 40L, + ), + ) + transaction.advanceExecution( + killExecution( + sequence, + phase = MutationExecutionPhase.EFFECTS_PENDING, + generation = 1, + attempt = 1, + lastAttemptAt = 40L, + ), + ) + transaction.advanceEffect( + killEffect( + sequence, + disposition = MutationEffectDisposition.APPLIED, + completedAt = 50L + sequence, + ), + ) + transaction.advanceExecution( + killExecution( + sequence, + phase = MutationExecutionPhase.RETIRED, + generation = 1, + attempt = 1, + lastAttemptAt = 40L, + retiredAt = 50L + sequence, + ), + ) + } + } + storage.transaction { transaction -> + transaction.advanceClient(killClient(lastAllocated = 4L, retiredThrough = 4L)) + transaction.confirmRetiredThrough( + clientId = KILL_CLIENT_ID, + requestedThroughSequence = 2L, + serverConfirmedThroughSequence = 2L, + ) + transaction.insertAlias(killAlias(sourceId = "alias-source", targetId = "alias-target")) + transaction.advanceAlias( + killAlias( + sourceId = "alias-source", + targetId = "alias-target", + state = MutationAliasState.ACTIVE, + activatedAt = 70L, + ), + ) + transaction.insertTombstone(killTombstone(sequence = 3L, canonicalId = "protected-active")) + transaction.advanceTombstone( + killTombstone( + sequence = 3L, + canonicalId = "protected-active", + state = MutationTombstoneState.ACTIVE, + activatedAt = 70L, + ), + ) + transaction.insertTombstone(killTombstone(sequence = 1L, canonicalId = "eligible-superseded")) + transaction.advanceTombstone( + killTombstone( + sequence = 1L, + canonicalId = "eligible-superseded", + state = MutationTombstoneState.ACTIVE, + activatedAt = 60L, + ), + ) + transaction.advanceTombstone( + killTombstone( + sequence = 1L, + canonicalId = "eligible-superseded", + state = MutationTombstoneState.SUPERSEDED, + activatedAt = 60L, + supersededBySequence = 2L, + supersededAt = 65L, + ), + ) + } +} + +private data class ProtectedSnapshot( + val rows: List, +) + +private fun protectedSnapshot(transaction: MutationJournalTransaction): ProtectedSnapshot { + val client = requireNotNull(transaction.client(KILL_CLIENT_ID)) + val rows = mutableListOf() + rows += + "client:${client.recordVersion}:${client.clientId}:${client.lastAllocatedSequence}:" + + "${client.retiredThroughSequence}:${client.serverConfirmedRetiredThroughSequence}:${client.createdAt}" + transaction.intents(KILL_CLIENT_ID).filter { it.clientSequence > 2L }.forEach { row -> + rows += + "intent:${row.rowId}:${row.recordVersion}:${row.clientId}:${row.clientSequence}:" + + "${row.mutationId}:${row.namespace}:${row.canonicalId}:${row.mutatorId}:" + + "${row.mutatorVersion}:${row.argsBlob.toList()}:${row.idempotencyRoot}:${row.createdAt}" + } + transaction.executions(KILL_CLIENT_ID).filter { it.clientSequence > 2L }.forEach { row -> + rows += + "execution:${row.clientId}:${row.clientSequence}:${row.phase}:${row.currentGeneration}:" + + "${row.attempt}:${row.lastAttemptAt}:${row.activeFailureId}:${row.retiredAt}" + } + transaction.attempts(KILL_CLIENT_ID).filter { it.clientSequence > 2L }.forEach { row -> + rows += + "attempt:${row.clientId}:${row.clientSequence}:${row.generation}:${row.effectiveNamespace}:" + + "${row.effectiveCanonicalId}:${row.valueCodecVersion}:${row.basePresence}:${row.baseBlob?.toList()}:" + + "${row.minePresence}:${row.mineBlob?.toList()}:${row.preconditionMetaPresent}:" + + "${row.preconditionWrittenAt}:${row.preconditionEtag}:${row.advertisedRetiredThroughSequence}:" + + "${row.generationIdempotencyKey}:${row.preparedAt}:${row.conflictMetaPresent}:" + + "${row.conflictWrittenAt}:${row.conflictEtag}:${row.conflictReceivedAt}" + } + transaction.acks(KILL_CLIENT_ID).filter { it.clientSequence > 2L }.forEach { row -> + rows += + "ack:${row.clientId}:${row.clientSequence}:${row.generation}:${row.authoritativePresence}:" + + "${row.authoritativeBlob?.toList()}:${row.valueCodecVersion}:${row.etag}:" + + "${row.canonicalTargetNamespace}:${row.canonicalTargetId}:${row.receivedAt}" + } + transaction.failures(KILL_CLIENT_ID).filter { it.clientSequence > 2L }.forEach { row -> + rows += + "failure:${row.failureId}:${row.clientId}:${row.clientSequence}:${row.generation}:" + + "${row.kind}:${row.detail}:${row.message}:${row.occurredAt}" + } + transaction.effects(KILL_CLIENT_ID).filter { it.clientSequence > 2L }.forEach { row -> + rows += + "effect:${row.clientId}:${row.clientSequence}:${row.effectIndex}:${row.kind}:" + + "${row.namespace}:${row.canonicalId}:${row.createdAt}:${row.disposition}:${row.completedAt}" + } + transaction.aliases().forEach { row -> + rows += + "alias:${row.sourceNamespace}:${row.sourceCanonicalId}:${row.targetNamespace}:" + + "${row.targetCanonicalId}:${row.state}:${row.createdByClientId}:${row.createdBySequence}:" + + "${row.createdAt}:${row.activatedAt}" + } + transaction.tombstones().filter { it.state != MutationTombstoneState.SUPERSEDED }.forEach { row -> + rows += + "tombstone:${row.namespace}:${row.canonicalId}:${row.createdByClientId}:" + + "${row.createdBySequence}:${row.state}:${row.createdAt}:${row.activatedAt}:" + + "${row.supersededByClientId}:${row.supersededBySequence}:${row.supersededAt}" + } + return ProtectedSnapshot(rows) +} + +private fun insertKillIntent( + transaction: MutationJournalTransaction, + sequence: Long, +) { + transaction.insertIntent( + recordVersion = 1, + clientId = KILL_CLIENT_ID, + clientSequence = sequence, + mutationId = "mutation-$sequence", + namespace = "items", + canonicalId = "item-$sequence", + mutatorId = "mutator", + mutatorVersion = 1, + argsBlob = byteArrayOf(sequence.toByte()), + idempotencyRoot = "root-$sequence", + createdAt = 10L + sequence, + ) +} + +private fun killClient( + lastAllocated: Long = 0L, + retiredThrough: Long = 0L, +): MutationClientRecord = + MutationClientRecord( + recordVersion = 1, + clientId = KILL_CLIENT_ID, + lastAllocatedSequence = lastAllocated, + retiredThroughSequence = retiredThrough, + serverConfirmedRetiredThroughSequence = 0L, + createdAt = 1L, + ) + +private fun killExecution( + sequence: Long, + phase: MutationExecutionPhase = MutationExecutionPhase.UNPREPARED, + generation: Int = 0, + attempt: Int = 0, + lastAttemptAt: Long? = null, + retiredAt: Long? = null, +): MutationExecutionRecord = + MutationExecutionRecord( + clientId = KILL_CLIENT_ID, + clientSequence = sequence, + phase = phase, + currentGeneration = generation, + attempt = attempt, + lastAttemptAt = lastAttemptAt, + activeFailureId = null, + retiredAt = retiredAt, + ) + +private fun killAttempt(sequence: Long): MutationAttemptRecord = + MutationAttemptRecord( + clientId = KILL_CLIENT_ID, + clientSequence = sequence, + generation = 1, + effectiveNamespace = "items", + effectiveCanonicalId = "item-$sequence", + valueCodecVersion = 1, + basePresence = MutationPresenceState.PRESENT, + baseBlob = byteArrayOf(1), + minePresence = MutationPresenceState.PRESENT, + mineBlob = byteArrayOf(sequence.toByte()), + preconditionMetaPresent = false, + preconditionWrittenAt = null, + preconditionEtag = null, + advertisedRetiredThroughSequence = 0L, + generationIdempotencyKey = "generation-$sequence-1", + preparedAt = 20L + sequence, + conflictMetaPresent = null, + conflictWrittenAt = null, + conflictEtag = null, + conflictReceivedAt = null, + ) + +private fun killAck(sequence: Long): MutationAckRecord = + MutationAckRecord( + clientId = KILL_CLIENT_ID, + clientSequence = sequence, + generation = 1, + authoritativePresence = MutationPresenceState.PRESENT, + authoritativeBlob = byteArrayOf(sequence.toByte()), + valueCodecVersion = 1, + etag = "etag-$sequence", + canonicalTargetNamespace = null, + canonicalTargetId = null, + receivedAt = 40L + sequence, + ) + +private fun killEffect( + sequence: Long, + disposition: MutationEffectDisposition = MutationEffectDisposition.PENDING, + completedAt: Long? = null, +): MutationEffectRecord = + MutationEffectRecord( + clientId = KILL_CLIENT_ID, + clientSequence = sequence, + effectIndex = 0, + kind = MutationEffectKind.KEY, + namespace = "effects", + canonicalId = "target-$sequence", + createdAt = 30L + sequence, + disposition = disposition, + completedAt = completedAt, + ) + +private fun killAlias( + sourceId: String = "item-1", + targetId: String = "canonical-1", + state: MutationAliasState = MutationAliasState.PENDING, + activatedAt: Long? = null, +): MutationKeyAliasRecord = + MutationKeyAliasRecord( + sourceNamespace = "items", + sourceCanonicalId = sourceId, + targetNamespace = "items", + targetCanonicalId = targetId, + state = state, + createdByClientId = KILL_CLIENT_ID, + createdBySequence = 1L, + createdAt = 10L, + activatedAt = activatedAt, + ) + +private fun killTombstone( + sequence: Long = 1L, + canonicalId: String = "deleted-item", + state: MutationTombstoneState = MutationTombstoneState.PENDING, + activatedAt: Long? = null, + supersededBySequence: Long? = null, + supersededAt: Long? = null, +): MutationKeyTombstoneRecord = + MutationKeyTombstoneRecord( + namespace = "items", + canonicalId = canonicalId, + createdByClientId = KILL_CLIENT_ID, + createdBySequence = sequence, + state = state, + createdAt = 10L, + activatedAt = activatedAt, + supersededByClientId = supersededBySequence?.let { KILL_CLIENT_ID }, + supersededBySequence = supersededBySequence, + supersededAt = supersededAt, + ) + +private const val KILL_CLIENT_ID: String = "kill-client" + +private fun runKillPointTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = 25.seconds, testBody = testBody) diff --git a/store6-mutations-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/testing/KillPointJournalStorage.kt b/store6-mutations-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/testing/KillPointJournalStorage.kt new file mode 100644 index 000000000..450c7133e --- /dev/null +++ b/store6-mutations-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/testing/KillPointJournalStorage.kt @@ -0,0 +1,144 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, +) + +package org.mobilenativefoundation.store6.mutations.testing + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionPhase +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationJournalStorage +import org.mobilenativefoundation.store6.mutations.storage.MutationJournalTransaction + +/** Semantic journal transaction boundaries available to deterministic crash tests. */ +@ExperimentalStoreApi +public enum class JournalStorageKillPoint { + BEFORE_RETIREMENT_FINALIZATION_COMMIT, + AFTER_RETIREMENT_FINALIZATION_COMMIT, + BEFORE_PRUNE, + BEFORE_PRUNE_COMMIT, + AFTER_PRUNE_COMMIT, +} + +/** One-shot simulated process crash raised by [KillPointJournalStorage]. */ +@ExperimentalStoreApi +public class JournalStorageCrashException( + /** The semantic boundary that aborted the caller. */ + public val killPoint: JournalStorageKillPoint, +) : RuntimeException("Simulated journal crash at $killPoint") + +/** + * One-shot deterministic crash decorator for [MutationJournalStorage]. + * + * [arm] rejects a second armed point. The selected point remains armed across unrelated + * transactions and clears before its crash is raised, so reopening or retrying cannot trip it + * again. Classification uses the transaction-entry execution phase and the operation invoked; + * it never depends on transaction ordinals or scheduler timing. + */ +@ExperimentalStoreApi +public class KillPointJournalStorage( + private val delegate: MutationJournalStorage, +) : MutationJournalStorage { + private val gate: Mutex = Mutex() + private var armed: JournalStorageKillPoint? = null + + /** Arms exactly one semantic transaction boundary. */ + public suspend fun arm(killPoint: JournalStorageKillPoint) { + gate.withLock { + check(armed == null) { "A journal kill point is already armed: $armed" } + armed = killPoint + } + } + + override suspend fun transaction( + block: (MutationJournalTransaction) -> R, + ): R = + gate.withLock { + var committedRetirementFinalization = false + var committedPrune = false + val result = + delegate.transaction { transaction -> + val observed = + ObservingJournalTransaction( + delegate = transaction, + beforePrune = { + if (armed == JournalStorageKillPoint.BEFORE_PRUNE) { + crash(JournalStorageKillPoint.BEFORE_PRUNE) + } + }, + ) + val value = block(observed) + if ( + observed.sawRetirementFinalization && + armed == JournalStorageKillPoint.BEFORE_RETIREMENT_FINALIZATION_COMMIT + ) { + crash(JournalStorageKillPoint.BEFORE_RETIREMENT_FINALIZATION_COMMIT) + } + if (observed.sawPrune && armed == JournalStorageKillPoint.BEFORE_PRUNE_COMMIT) { + crash(JournalStorageKillPoint.BEFORE_PRUNE_COMMIT) + } + committedRetirementFinalization = observed.sawRetirementFinalization + committedPrune = observed.sawPrune + value + } + if ( + committedRetirementFinalization && + armed == JournalStorageKillPoint.AFTER_RETIREMENT_FINALIZATION_COMMIT + ) { + crash(JournalStorageKillPoint.AFTER_RETIREMENT_FINALIZATION_COMMIT) + } + if (committedPrune && armed == JournalStorageKillPoint.AFTER_PRUNE_COMMIT) { + crash(JournalStorageKillPoint.AFTER_PRUNE_COMMIT) + } + result + } + + private fun crash(killPoint: JournalStorageKillPoint): Nothing { + check(armed == killPoint) + armed = null + throw JournalStorageCrashException(killPoint) + } +} + +private class ObservingJournalTransaction( + private val delegate: MutationJournalTransaction, + private val beforePrune: () -> Unit, +) : MutationJournalTransaction by delegate { + private val entryPhases = mutableMapOf, MutationExecutionPhase>() + + var sawRetirementFinalization: Boolean = false + private set + var sawPrune: Boolean = false + private set + + override fun advanceExecution(record: MutationExecutionRecord) { + val identity = record.clientId to record.clientSequence + val entryPhase = + entryPhases.getOrPut(identity) { + requireNotNull( + delegate.executions(record.clientId).firstOrNull { previous -> + previous.clientSequence == record.clientSequence + }, + ).phase + } + delegate.advanceExecution(record) + if ( + entryPhase == MutationExecutionPhase.EFFECTS_PENDING && + record.phase == MutationExecutionPhase.RETIRED + ) { + sawRetirementFinalization = true + } + } + + override fun prune( + clientId: String, + serverConfirmedRetiredThroughSequence: Long, + ) { + beforePrune() + delegate.prune(clientId, serverConfirmedRetiredThroughSequence) + sawPrune = true + } +} diff --git a/store6-mutations-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/testing/MutationJournalStorageContractKit.kt b/store6-mutations-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/testing/MutationJournalStorageContractKit.kt new file mode 100644 index 000000000..15553a889 --- /dev/null +++ b/store6-mutations-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/testing/MutationJournalStorageContractKit.kt @@ -0,0 +1,3327 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.mutations.testing + +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.mutations.MutationFailureKind +import org.mobilenativefoundation.store6.mutations.MutationPresenceState +import org.mobilenativefoundation.store6.mutations.storage.MutationAckRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationAliasState +import org.mobilenativefoundation.store6.mutations.storage.MutationAttemptRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationClientRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationEffectDisposition +import org.mobilenativefoundation.store6.mutations.storage.MutationEffectKind +import org.mobilenativefoundation.store6.mutations.storage.MutationEffectRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionPhase +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationFailureRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationIntentRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationJournalStorage +import org.mobilenativefoundation.store6.mutations.storage.MutationJournalTransaction +import org.mobilenativefoundation.store6.mutations.storage.MutationKeyAliasRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationKeyTombstoneRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationTombstoneState +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +/** + * Conformance kit for durable mutation-journal storage implementations. + * + * Extend this class in a consumer test source set and run the inherited tests on every supported + * target. [reopenStorage] is the restart boundary: an in-memory implementation returns the same + * instance (the in-memory restart analogue), while a persistent implementation returns a new + * adapter over the same durable store. + * + * The kit enforces the named phase rules rather than a false total ordering. In particular, + * `INFLIGHT -> READY` is legal after a transport failure, and `REFRESH_REQUIRED` may advance to a + * new immutable generation before returning to `READY`. `RETIRED` and `PARKED` are terminal; + * `ACKED` and `EFFECTS_PENDING` never regress to a pre-ack phase. + * + * All writes use one generic composable transaction door. A thrown callback commits none of its + * operations. Ordinary pruning is limited to the persisted server-confirmed retirement prefix; + * alias redirects and the active tombstone generation survive it. + */ +@ExperimentalStoreApi +public abstract class MutationJournalStorageContractKit : JournalStorageKillPointScenarios() { + /** Creates a fresh storage instance for one inherited contract test. */ + public abstract override fun createStorage(): MutationJournalStorage + + /** Reopens [previous] without clearing its durable records. */ + public abstract override fun reopenStorage(previous: MutationJournalStorage): MutationJournalStorage + + @Test + public fun clientRecord_enforcesThreeWayInvariantAndMonotonicConfirmation(): TestResult = + runTest { + assertFailsWith { + client(lastAllocated = 1L, retiredThrough = 2L) + } + assertFailsWith { + client(lastAllocated = 2L, retiredThrough = 1L, confirmedThrough = 2L) + } + + val storage = createStorage() + assertFailsWith { + storage.transaction { transaction -> + transaction.insertClient(client(lastAllocated = 1L)) + } + } + storage.transaction { transaction -> + transaction.insertClient(client()) + transaction.advanceClient( + client( + lastAllocated = 3L, + retiredThrough = 2L, + ), + ) + } + + assertFailsWith { + storage.transaction { transaction -> + transaction.confirmRetiredThrough( + clientId = CLIENT_ID, + requestedThroughSequence = 1L, + serverConfirmedThroughSequence = 2L, + ) + } + } + storage.transaction { transaction -> + transaction.confirmRetiredThrough( + clientId = CLIENT_ID, + requestedThroughSequence = 2L, + serverConfirmedThroughSequence = 1L, + ) + } + assertFailsWith { + storage.transaction { transaction -> + transaction.advanceClient( + client( + lastAllocated = 2L, + retiredThrough = 2L, + confirmedThrough = 1L, + ), + ) + } + } + assertFailsWith { + storage.transaction { transaction -> + transaction.advanceClient( + client( + lastAllocated = 3L, + retiredThrough = 1L, + confirmedThrough = 1L, + ), + ) + } + } + assertFailsWith { + storage.transaction { transaction -> + transaction.advanceClient( + client( + lastAllocated = 3L, + retiredThrough = 2L, + confirmedThrough = 2L, + ), + ) + } + } + assertFailsWith { + storage.transaction { transaction -> + transaction.confirmRetiredThrough( + clientId = CLIENT_ID, + requestedThroughSequence = 2L, + serverConfirmedThroughSequence = 0L, + ) + } + } + + storage.transaction { transaction -> + transaction.confirmRetiredThrough( + clientId = CLIENT_ID, + requestedThroughSequence = 2L, + serverConfirmedThroughSequence = 2L, + ) + } + val persisted = storage.transaction { it.client(CLIENT_ID) } + assertEquals(3L, requireNotNull(persisted).lastAllocatedSequence) + assertEquals(2L, persisted.retiredThroughSequence) + assertEquals(2L, persisted.serverConfirmedRetiredThroughSequence) + } + + @Test + public fun intent_isImmutableUniqueAndOrderedByClientSequence(): TestResult = runTest { + val storage = createStorage() + storage.transaction { transaction -> + transaction.insertClient(client()) + transaction.advanceClient(client(lastAllocated = 2L)) + insertIntent(transaction, sequence = 2L, mutationId = "mutation-2") + transaction.insertExecution(execution(sequence = 2L)) + insertIntent(transaction, sequence = 1L, mutationId = "mutation-1") + transaction.insertExecution(execution(sequence = 1L)) + } + + val ordered = storage.transaction { it.intents(CLIENT_ID) } + assertEquals(listOf(1L, 2L), ordered.map { it.clientSequence }) + assertEquals(listOf("mutation-1", "mutation-2"), ordered.map { it.mutationId }) + + assertFailsWith { + storage.transaction { transaction -> + insertIntent(transaction, sequence = 1L, mutationId = "other-id") + } + } + storage.transaction { transaction -> + transaction.advanceClient(client(lastAllocated = 3L)) + } + assertFailsWith { + storage.transaction { transaction -> + insertIntent(transaction, sequence = 3L, mutationId = "mutation-1") + } + } + + val afterRejectedWrites = storage.transaction { it.intents(CLIENT_ID) } + assertEquals(2, afterRejectedWrites.size) + assertEquals("items", afterRejectedWrites.first().namespace) + } + + @Test + public fun enqueue_intentAndUnpreparedExecutionCommitAtomically(): TestResult = runTest { + val storage = createStorage() + storage.transaction { it.insertClient(client()) } + val failure = Rollback() + + val caught = + assertFailsWith { + storage.transaction { transaction -> + transaction.advanceClient(client(lastAllocated = 1L)) + insertIntent(transaction, sequence = 1L) + transaction.insertExecution(execution(sequence = 1L)) + throw failure + } + } + assertSame(failure, caught) + storage.transaction { transaction -> + assertEquals(0L, requireNotNull(transaction.client(CLIENT_ID)).lastAllocatedSequence) + assertTrue(transaction.intents(CLIENT_ID).isEmpty()) + assertTrue(transaction.executions(CLIENT_ID).isEmpty()) + } + + storage.transaction { transaction -> + transaction.advanceClient(client(lastAllocated = 1L)) + insertIntent(transaction, sequence = 1L) + transaction.insertExecution(execution(sequence = 1L)) + } + storage.transaction { transaction -> + assertEquals(1L, requireNotNull(transaction.client(CLIENT_ID)).lastAllocatedSequence) + assertEquals(1, transaction.intents(CLIENT_ID).size) + assertEquals(MutationExecutionPhase.UNPREPARED, transaction.executions(CLIENT_ID).single().phase) + } + } + + @Test + public fun execution_preservesRuledLegalEdgesAndTerminalStates(): TestResult = runTest { + assertFailsWith { + execution( + sequence = 1L, + attemptCount = 1, + lastAttemptAt = 1L, + ) + } + + val readyParkingStorage = createStorage() + appendIntent(readyParkingStorage, sequence = 1L) + readyParkingStorage.transaction { transaction -> + transaction.insertAttempt(attempt(sequence = 1L, generation = 1)) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.READY, + generation = 1, + ), + ) + } + assertFailsWith { + readyParkingStorage.transaction { transaction -> + val failure = + transaction.appendFailure( + clientId = CLIENT_ID, + clientSequence = 1L, + generation = 1, + kind = MutationFailureKind.PROTOCOL, + detail = "ready", + message = "parking before an invocation completes", + occurredAt = 30L, + ) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.PARKED, + generation = 1, + attemptCount = 1, + lastAttemptAt = 30L, + activeFailureId = failure.failureId, + ), + ) + } + } + + suspend fun inflightParkingStorage( + withCompletedAttempt: Boolean = false, + ): MutationJournalStorage = + createStorage().also { candidate -> + appendIntent(candidate, sequence = 1L) + prepareForPush(candidate, sequence = 1L) + if (withCompletedAttempt) { + candidate.transaction { transaction -> + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.READY, + generation = 1, + attemptCount = 1, + lastAttemptAt = 30L, + ), + ) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.INFLIGHT, + generation = 1, + attemptCount = 1, + lastAttemptAt = 30L, + ), + ) + } + } + } + + suspend fun parkInflight( + candidate: MutationJournalStorage, + kind: MutationFailureKind, + attemptCount: Int, + lastAttemptAt: Long?, + ): MutationFailureRecord = + candidate.transaction { transaction -> + val failure = + transaction.appendFailure( + clientId = CLIENT_ID, + clientSequence = 1L, + generation = 1, + kind = kind, + detail = "inflight", + message = "terminal invocation failure", + occurredAt = 40L, + ) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.PARKED, + generation = 1, + attemptCount = attemptCount, + lastAttemptAt = lastAttemptAt, + activeFailureId = failure.failureId, + ), + ) + failure + } + + listOf(MutationFailureKind.IDENTITY, MutationFailureKind.CODEC).forEach { kind -> + val candidate = inflightParkingStorage(withCompletedAttempt = true) + val failure = + parkInflight( + candidate = candidate, + kind = kind, + attemptCount = 1, + lastAttemptAt = 30L, + ) + val parked = candidate.transaction { it.executions(CLIENT_ID).single() } + val activeFailure = + candidate.transaction { transaction -> + transaction.failures(CLIENT_ID).single { it.failureId == parked.activeFailureId } + } + assertEquals(MutationExecutionPhase.PARKED, parked.phase) + assertEquals(1, parked.currentGeneration) + assertEquals(1, parked.attempt) + assertEquals(30L, parked.lastAttemptAt) + assertEquals(failure.failureId, parked.activeFailureId) + assertEquals(kind, activeFailure.kind) + } + + val changedTimestampStorage = inflightParkingStorage(withCompletedAttempt = true) + assertFailsWith { + parkInflight( + candidate = changedTimestampStorage, + kind = MutationFailureKind.IDENTITY, + attemptCount = 1, + lastAttemptAt = 31L, + ) + } + assertTrue(changedTimestampStorage.transaction { it.failures(CLIENT_ID).isEmpty() }) + + val unchangedTransportStorage = inflightParkingStorage() + assertFailsWith { + parkInflight( + candidate = unchangedTransportStorage, + kind = MutationFailureKind.TRANSPORT, + attemptCount = 0, + lastAttemptAt = null, + ) + } + assertTrue(unchangedTransportStorage.transaction { it.failures(CLIENT_ID).isEmpty() }) + + val skippedAttemptStorage = inflightParkingStorage() + assertFailsWith { + parkInflight( + candidate = skippedAttemptStorage, + kind = MutationFailureKind.TRANSPORT, + attemptCount = 2, + lastAttemptAt = 40L, + ) + } + assertTrue(skippedAttemptStorage.transaction { it.failures(CLIENT_ID).isEmpty() }) + + val completedParkingStorage = inflightParkingStorage() + parkInflight( + candidate = completedParkingStorage, + kind = MutationFailureKind.TRANSPORT, + attemptCount = 1, + lastAttemptAt = 40L, + ) + val completedParking = completedParkingStorage.transaction { it.executions(CLIENT_ID).single() } + assertEquals(MutationExecutionPhase.PARKED, completedParking.phase) + assertEquals(1, completedParking.attempt) + assertEquals(40L, completedParking.lastAttemptAt) + + val storage = createStorage() + appendIntent(storage, sequence = 1L) + storage.transaction { transaction -> + transaction.insertAttempt(attempt(sequence = 1L, generation = 1)) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.READY, + generation = 1, + ), + ) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.INFLIGHT, + generation = 1, + ), + ) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.READY, + generation = 1, + attemptCount = 1, + lastAttemptAt = 30L, + ), + ) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.INFLIGHT, + generation = 1, + attemptCount = 1, + lastAttemptAt = 30L, + ), + ) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.REFRESH_REQUIRED, + generation = 1, + attemptCount = 2, + lastAttemptAt = 40L, + ), + ) + } + assertFailsWith { + storage.transaction { transaction -> + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.READY, + generation = 1, + attemptCount = 2, + lastAttemptAt = 40L, + ), + ) + } + } + suspend fun assertGenerationContinuationRejected( + effectiveNamespace: String, + effectiveCanonicalId: String, + expectedMessage: String, + ) { + val failure = + assertFailsWith { + storage.transaction { transaction -> + transaction.insertAttempt( + attempt( + sequence = 1L, + generation = 2, + effectiveNamespace = effectiveNamespace, + effectiveCanonicalId = effectiveCanonicalId, + ), + ) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.READY, + generation = 2, + ), + ) + } + } + assertEquals(expectedMessage, failure.message) + storage.transaction { transaction -> + val persisted = + transaction.executions(CLIENT_ID).single { it.clientSequence == 1L } + assertEquals(MutationExecutionPhase.REFRESH_REQUIRED, persisted.phase) + assertEquals(1, persisted.currentGeneration) + assertEquals( + listOf(1), + transaction.attempts(CLIENT_ID) + .filter { it.clientSequence == 1L } + .map { it.generation }, + ) + } + } + + assertGenerationContinuationRejected( + effectiveNamespace = "items", + effectiveCanonicalId = "retargeted", + expectedMessage = "generation continuation must preserve exact effective identity", + ) + assertGenerationContinuationRejected( + effectiveNamespace = "moved-items", + effectiveCanonicalId = "item-1", + expectedMessage = "generation continuation must preserve exact effective identity", + ) + + appendIntent( + storage = storage, + sequence = 2L, + namespace = "occupied-items", + canonicalId = "occupied-owner", + ) + storage.transaction { transaction -> + transaction.insertAttempt( + attempt( + sequence = 2L, + generation = 1, + effectiveNamespace = "occupied-items", + effectiveCanonicalId = "occupied-owner", + ), + ) + transaction.advanceExecution( + execution( + sequence = 2L, + phase = MutationExecutionPhase.READY, + generation = 1, + ), + ) + transaction.advanceExecution( + execution( + sequence = 2L, + phase = MutationExecutionPhase.INFLIGHT, + generation = 1, + ), + ) + } + assertGenerationContinuationRejected( + effectiveNamespace = "occupied-items", + effectiveCanonicalId = "item-1", + expectedMessage = "namespace authority already has an owner", + ) + + storage.transaction { transaction -> + transaction.insertAttempt(attempt(sequence = 1L, generation = 2)) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.READY, + generation = 2, + ), + ) + } + + val generationTwo = + storage.transaction { + it.executions(CLIENT_ID).single { execution -> execution.clientSequence == 1L } + } + assertEquals(MutationExecutionPhase.READY, generationTwo.phase) + assertEquals(2, generationTwo.currentGeneration) + val continuationAttempts = + storage.transaction { + it.attempts(CLIENT_ID) + .filter { attempt -> attempt.clientSequence == 1L } + .sortedBy { attempt -> attempt.generation } + } + assertEquals( + listOf("items" to "item-1", "items" to "item-1"), + continuationAttempts.map { it.effectiveNamespace to it.effectiveCanonicalId }, + ) + + storage.transaction { transaction -> + val failure = + transaction.appendFailure( + clientId = CLIENT_ID, + clientSequence = 1L, + generation = 2, + kind = MutationFailureKind.PROTOCOL, + detail = "terminal", + message = "terminal parking", + occurredAt = 50L, + ) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.PARKED, + generation = 2, + activeFailureId = failure.failureId, + ), + ) + } + assertFailsWith { + storage.transaction { transaction -> + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.READY, + generation = 2, + ), + ) + } + } + + val acknowledgedStorage = createStorage() + appendIntent(acknowledgedStorage, sequence = 1L) + prepareForPush(acknowledgedStorage, sequence = 1L) + acknowledgedStorage.transaction { transaction -> + transaction.insertAck(ack(sequence = 1L, generation = 1)) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.ACKED, + generation = 1, + attemptCount = 1, + lastAttemptAt = 50L, + ), + ) + } + assertFailsWith { + acknowledgedStorage.transaction { transaction -> + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.READY, + generation = 1, + attemptCount = 1, + lastAttemptAt = 50L, + ), + ) + } + } + acknowledgedStorage.transaction { transaction -> + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.EFFECTS_PENDING, + generation = 1, + attemptCount = 1, + lastAttemptAt = 50L, + ), + ) + } + assertFailsWith { + acknowledgedStorage.transaction { transaction -> + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.INFLIGHT, + generation = 1, + attemptCount = 1, + lastAttemptAt = 50L, + ), + ) + } + } + + val retiredStorage = createStorage() + appendIntent(retiredStorage, sequence = 1L) + prepareConflict(retiredStorage, sequence = 1L) + retiredStorage.transaction { transaction -> + transaction.advanceEffect( + effect( + sequence = 1L, + index = 0, + disposition = MutationEffectDisposition.SKIPPED, + completedAt = 50L, + ), + ) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.RETIRED, + generation = 1, + attemptCount = 1, + lastAttemptAt = 40L, + retiredAt = 50L, + ), + ) + } + assertFailsWith { + retiredStorage.transaction { transaction -> + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.READY, + generation = 1, + attemptCount = 1, + lastAttemptAt = 40L, + ), + ) + } + } + + val namespaceOwnerStorage = createStorage() + appendIntent( + storage = namespaceOwnerStorage, + sequence = 1L, + canonicalId = "owner", + ) + appendIntent( + storage = namespaceOwnerStorage, + sequence = 2L, + canonicalId = "blocked", + ) + appendIntent( + storage = namespaceOwnerStorage, + sequence = 3L, + canonicalId = "after-retirement", + ) + appendIntent( + storage = namespaceOwnerStorage, + sequence = 4L, + namespace = "other-items", + canonicalId = "independent", + ) + namespaceOwnerStorage.transaction { transaction -> + listOf( + Triple(1L, "items", "owner"), + Triple(2L, "items", "blocked"), + Triple(3L, "items", "after-retirement"), + Triple(4L, "other-items", "independent"), + ).forEach { (sequence, namespace, canonicalId) -> + transaction.insertAttempt( + attempt( + sequence = sequence, + generation = 1, + effectiveNamespace = namespace, + effectiveCanonicalId = canonicalId, + ), + ) + transaction.advanceExecution( + execution( + sequence = sequence, + phase = MutationExecutionPhase.READY, + generation = 1, + ), + ) + } + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.INFLIGHT, + generation = 1, + ), + ) + } + + assertFailsWith { + namespaceOwnerStorage.transaction { transaction -> + transaction.advanceExecution( + execution( + sequence = 2L, + phase = MutationExecutionPhase.INFLIGHT, + generation = 1, + ), + ) + } + } + namespaceOwnerStorage.transaction { transaction -> + val phases = + transaction.executions(CLIENT_ID).associate { it.clientSequence to it.phase } + assertEquals(MutationExecutionPhase.INFLIGHT, phases.getValue(1L)) + assertEquals(MutationExecutionPhase.READY, phases.getValue(2L)) + } + + namespaceOwnerStorage.transaction { transaction -> + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.READY, + generation = 1, + attemptCount = 1, + lastAttemptAt = 30L, + ), + ) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.INFLIGHT, + generation = 1, + attemptCount = 1, + lastAttemptAt = 30L, + ), + ) + } + namespaceOwnerStorage.transaction { transaction -> + val failure = + transaction.appendFailure( + clientId = CLIENT_ID, + clientSequence = 1L, + generation = 1, + kind = MutationFailureKind.TRANSPORT, + detail = "owner-release", + message = "owner parked after transport", + occurredAt = 40L, + ) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.PARKED, + generation = 1, + attemptCount = 2, + lastAttemptAt = 40L, + activeFailureId = failure.failureId, + ), + ) + } + namespaceOwnerStorage.transaction { transaction -> + transaction.advanceExecution( + execution( + sequence = 2L, + phase = MutationExecutionPhase.INFLIGHT, + generation = 1, + ), + ) + transaction.advanceExecution( + execution( + sequence = 4L, + phase = MutationExecutionPhase.INFLIGHT, + generation = 1, + ), + ) + } + namespaceOwnerStorage.transaction { transaction -> + transaction.insertAck(sameIdentityAck(sequence = 2L, generation = 1)) + transaction.advanceExecution( + execution( + sequence = 2L, + phase = MutationExecutionPhase.ACKED, + generation = 1, + attemptCount = 1, + lastAttemptAt = 50L, + ), + ) + transaction.advanceExecution( + execution( + sequence = 2L, + phase = MutationExecutionPhase.EFFECTS_PENDING, + generation = 1, + attemptCount = 1, + lastAttemptAt = 50L, + ), + ) + transaction.advanceExecution( + execution( + sequence = 2L, + phase = MutationExecutionPhase.RETIRED, + generation = 1, + attemptCount = 1, + lastAttemptAt = 50L, + retiredAt = 60L, + ), + ) + } + namespaceOwnerStorage.transaction { transaction -> + transaction.advanceExecution( + execution( + sequence = 3L, + phase = MutationExecutionPhase.INFLIGHT, + generation = 1, + ), + ) + val phases = + transaction.executions(CLIENT_ID).associate { it.clientSequence to it.phase } + assertEquals(MutationExecutionPhase.PARKED, phases.getValue(1L)) + assertEquals(MutationExecutionPhase.RETIRED, phases.getValue(2L)) + assertEquals(MutationExecutionPhase.INFLIGHT, phases.getValue(3L)) + assertEquals(MutationExecutionPhase.INFLIGHT, phases.getValue(4L)) + } + + C8OwnerState.entries.forEach { ownerState -> + val candidate = c8OwnerPredicateStorage(ownerState) + assertFailsWith(ownerState.name) { + candidate.transaction { transaction -> + transaction.advanceExecution( + execution( + sequence = 2L, + phase = MutationExecutionPhase.INFLIGHT, + generation = 1, + ), + ) + } + } + candidate.transaction { transaction -> + val executions = transaction.executions(CLIENT_ID).associateBy { it.clientSequence } + assertEquals(ownerState.phase, executions.getValue(1L).phase, ownerState.name) + assertEquals(MutationExecutionPhase.READY, executions.getValue(2L).phase, ownerState.name) + } + } + } + + @Test + public fun execution_ackedRequiresMatchingAcknowledgement(): TestResult = runTest { + val storage = createStorage() + appendIntent(storage, sequence = 1L) + prepareForPush(storage, sequence = 1L) + + assertFailsWith { + storage.transaction { transaction -> + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.ACKED, + generation = 1, + attemptCount = 1, + lastAttemptAt = 50L, + ), + ) + } + } + + storage.transaction { transaction -> + assertTrue(transaction.acks(CLIENT_ID).isEmpty()) + assertEquals( + MutationExecutionPhase.INFLIGHT, + transaction.executions(CLIENT_ID).single().phase, + ) + } + } + + @Test + public fun persistedEnums_roundTripEveryStableName(): TestResult = runTest { + assertEquals( + listOf( + "UNPREPARED", + "READY", + "INFLIGHT", + "REFRESH_REQUIRED", + "ACKED", + "EFFECTS_PENDING", + "PARKED", + "RETIRED", + ), + MutationExecutionPhase.entries.map { it.name }, + ) + assertEquals(listOf("KEY", "NAMESPACE"), MutationEffectKind.entries.map { it.name }) + assertEquals( + listOf("PENDING", "APPLIED", "SKIPPED"), + MutationEffectDisposition.entries.map { it.name }, + ) + assertEquals(listOf("PENDING", "ACTIVE"), MutationAliasState.entries.map { it.name }) + assertEquals( + listOf("PENDING", "ACTIVE", "SUPERSEDED"), + MutationTombstoneState.entries.map { it.name }, + ) + assertEquals( + listOf("PRESENT", "ABSENT"), + MutationPresenceState.entries.map { it.name }, + ) + assertEquals( + listOf( + "IDENTITY", + "CODEC", + "PROJECTION", + "PROTOCOL", + "CONFLICT", + "TRANSPORT", + "ADOPTION", + "EFFECT", + "PERSISTENCE", + ), + MutationFailureKind.entries.map { it.name }, + ) + + val storage = createStorage() + appendIntent(storage, sequence = 1L) + storage.transaction { transaction -> + transaction.insertEffect(effect(sequence = 1L, index = 0, kind = MutationEffectKind.KEY)) + transaction.insertEffect( + effect(sequence = 1L, index = 1, kind = MutationEffectKind.NAMESPACE), + ) + transaction.insertAlias(alias()) + transaction.insertTombstone(tombstone()) + } + storage.transaction { transaction -> + assertEquals( + listOf("KEY", "NAMESPACE"), + transaction.effects(CLIENT_ID).map { it.kind.name }, + ) + assertEquals("PENDING", transaction.aliases().single().state.name) + assertEquals("PENDING", transaction.tombstones().single().state.name) + } + } + + @Test + public fun attemptGeneration_isImmutableAndAppendOnly(): TestResult = runTest { + val storage = createStorage() + appendIntent(storage, sequence = 1L) + storage.transaction { transaction -> + transaction.insertAttempt(attempt(sequence = 1L, generation = 1)) + transaction.recordConflictReceipt( + attempt( + sequence = 1L, + generation = 1, + conflictMetaPresent = true, + conflictWrittenAt = 31L, + conflictEtag = "conflict-etag", + conflictReceivedAt = 32L, + ), + ) + transaction.insertAttempt(attempt(sequence = 1L, generation = 2, mine = byteArrayOf(8))) + } + + appendIntent(storage, sequence = 2L) + assertFailsWith { + storage.transaction { transaction -> + transaction.insertAttempt( + attempt( + sequence = 2L, + generation = 1, + generationIdempotencyKey = "generation-1-1", + ), + ) + } + } + + assertFailsWith { + storage.transaction { transaction -> + transaction.recordConflictReceipt( + attempt( + sequence = 1L, + generation = 1, + effectiveCanonicalId = "changed", + conflictMetaPresent = true, + conflictWrittenAt = 31L, + conflictEtag = "conflict-etag", + conflictReceivedAt = 32L, + ), + ) + } + } + assertFailsWith { + storage.transaction { transaction -> + transaction.recordConflictReceipt( + attempt( + sequence = 1L, + generation = 1, + conflictMetaPresent = false, + conflictReceivedAt = 33L, + ), + ) + } + } + val attempts = storage.transaction { it.attempts(CLIENT_ID) } + assertEquals(listOf(1, 2), attempts.map { it.generation }) + assertEquals("item-1", attempts.first().effectiveCanonicalId) + assertEquals(true, attempts.first().conflictMetaPresent) + assertEquals(31L, attempts.first().conflictWrittenAt) + assertEquals("conflict-etag", attempts.first().conflictEtag) + assertEquals(32L, attempts.first().conflictReceivedAt) + assertContentEquals(byteArrayOf(8), attempts.last().mineBlob) + } + + @Test + public fun ack_isWriteOnceAndMismatchDoesNotOverwrite(): TestResult = runTest { + val storage = createStorage() + appendIntent(storage, sequence = 1L) + storage.transaction { transaction -> + transaction.insertAttempt(attempt(sequence = 1L, generation = 1)) + transaction.insertAck(ack(sequence = 1L, generation = 1)) + transaction.insertAck(ack(sequence = 1L, generation = 1)) + transaction.insertAck(ack(sequence = 1L, generation = 1, receivedAt = 51L)) + } + + assertFailsWith { + storage.transaction { transaction -> + transaction.insertAck( + ack( + sequence = 1L, + generation = 1, + authoritative = byteArrayOf(9), + ), + ) + } + } + val persisted = storage.transaction { it.acks(CLIENT_ID).single() } + assertContentEquals(byteArrayOf(7), persisted.authoritativeBlob) + assertEquals(50L, persisted.receivedAt) + } + + @Test + public fun failure_isAppendOnlyAndUtf8BoundedAtCodePointBoundary(): TestResult = runTest { + val storage = createStorage() + appendIntent(storage, sequence = 1L) + val detail = "D".repeat(127) + "🙂" + val message = "M".repeat(1_023) + "🙂" + + storage.transaction { transaction -> + transaction.appendFailure( + clientId = CLIENT_ID, + clientSequence = 1L, + generation = 0, + kind = MutationFailureKind.CODEC, + detail = detail, + message = message, + occurredAt = 10L, + ) + transaction.appendFailure( + clientId = CLIENT_ID, + clientSequence = 1L, + generation = 0, + kind = MutationFailureKind.PERSISTENCE, + detail = "second", + message = "second failure", + occurredAt = 11L, + ) + } + + val failures = storage.transaction { it.failures(CLIENT_ID) } + assertEquals(2, failures.size) + assertEquals(2, failures.map { it.failureId }.toSet().size) + assertEquals(failures.map { it.failureId }.sorted(), failures.map { it.failureId }) + val truncated = failures.single { it.occurredAt == 10L } + assertTrue(truncated.detail.encodeToByteArray().size <= 128) + assertTrue(truncated.message.encodeToByteArray().size <= 1_024) + assertEquals("D".repeat(127), truncated.detail) + assertEquals("M".repeat(1_023), truncated.message) + assertEquals(MutationFailureKind.CODEC, truncated.kind) + } + + @Test + public fun effect_startsPendingAndAdvancesDispositionForwardOnly(): TestResult = runTest { + assertFailsWith { + effect( + sequence = 1L, + index = 0, + disposition = MutationEffectDisposition.PENDING, + completedAt = 10L, + ) + } + val storage = createStorage() + appendIntent(storage, sequence = 1L) + assertFailsWith { + storage.transaction { transaction -> + transaction.insertEffect( + effect( + sequence = 1L, + index = 0, + disposition = MutationEffectDisposition.APPLIED, + completedAt = 19L, + ), + ) + } + } + storage.transaction { transaction -> + transaction.insertEffect(effect(sequence = 1L, index = 0)) + transaction.advanceEffect( + effect( + sequence = 1L, + index = 0, + disposition = MutationEffectDisposition.APPLIED, + completedAt = 20L, + ), + ) + } + assertFailsWith { + storage.transaction { transaction -> + transaction.insertEffect(effect(sequence = 1L, index = 0)) + } + } + assertFailsWith { + storage.transaction { transaction -> + transaction.advanceEffect(effect(sequence = 1L, index = 0)) + } + } + val persisted = storage.transaction { it.effects(CLIENT_ID).single() } + assertEquals(MutationEffectDisposition.APPLIED, persisted.disposition) + assertEquals(20L, persisted.completedAt) + } + + @Test + public fun alias_rejectsSelfEdgeRetargetAndCycle(): TestResult = runTest { + val storage = createStorage() + assertFailsWith { + storage.transaction { transaction -> + transaction.insertAlias( + alias(sourceId = "same", targetId = "same"), + ) + } + } + + storage.transaction { it.insertAlias(alias(sourceId = "a", targetId = "b")) } + assertFailsWith { + storage.transaction { transaction -> + transaction.insertAlias(alias(sourceId = "a", targetId = "c")) + } + } + assertFailsWith { + storage.transaction { transaction -> + transaction.insertAlias(alias(sourceId = "b", targetId = "a")) + } + } + assertEquals(1, storage.transaction { it.aliases().size }) + } + + @Test + public fun alias_equalDuplicateIsIdempotentAndActivationIsMonotonic(): TestResult = runTest { + val storage = createStorage() + storage.transaction { transaction -> + transaction.insertAlias(alias()) + transaction.insertAlias(alias()) + transaction.advanceAlias(alias(state = MutationAliasState.ACTIVE, activatedAt = 20L)) + transaction.insertAlias(alias(sequence = 2L)) + } + assertFailsWith { + storage.transaction { transaction -> transaction.advanceAlias(alias()) } + } + val persisted = storage.transaction { it.aliases().single() } + assertEquals(MutationAliasState.ACTIVE, persisted.state) + assertEquals(20L, persisted.activatedAt) + } + + @Test + public fun tombstone_allowsAtMostOnePendingAndOneActiveGeneration(): TestResult = runTest { + val storage = createStorage() + storage.transaction { it.insertTombstone(tombstone(sequence = 1L)) } + assertFailsWith { + storage.transaction { transaction -> + transaction.insertTombstone(tombstone(sequence = 2L)) + } + } + + storage.transaction { transaction -> + transaction.advanceTombstone( + tombstone( + sequence = 1L, + state = MutationTombstoneState.ACTIVE, + activatedAt = 20L, + ), + ) + transaction.insertTombstone(tombstone(sequence = 2L, createdAt = 21L)) + } + assertFailsWith { + storage.transaction { transaction -> + transaction.insertTombstone(tombstone(sequence = 3L, createdAt = 22L)) + } + } + assertFailsWith { + storage.transaction { transaction -> + transaction.advanceTombstone( + tombstone( + sequence = 2L, + createdAt = 21L, + state = MutationTombstoneState.ACTIVE, + activatedAt = 22L, + ), + ) + } + } + + val generations = storage.transaction { it.tombstones() } + assertEquals(2, generations.size) + assertEquals(1, generations.count { it.state == MutationTombstoneState.ACTIVE }) + assertEquals(1, generations.count { it.state == MutationTombstoneState.PENDING }) + } + + @Test + public fun tombstone_generationAdvancesForwardOnly(): TestResult = runTest { + val storage = createStorage() + storage.transaction { transaction -> + transaction.insertTombstone(tombstone(sequence = 1L)) + transaction.advanceTombstone( + tombstone( + sequence = 1L, + state = MutationTombstoneState.ACTIVE, + activatedAt = 20L, + ), + ) + } + assertFailsWith { + storage.transaction { transaction -> + transaction.advanceTombstone( + tombstone( + sequence = 1L, + state = MutationTombstoneState.SUPERSEDED, + activatedAt = 999L, + supersededBySequence = 9L, + supersededAt = 30L, + ), + ) + } + } + val stillActive = storage.transaction { it.tombstones().single() } + assertEquals(MutationTombstoneState.ACTIVE, stillActive.state) + assertEquals(20L, stillActive.activatedAt) + + storage.transaction { transaction -> + transaction.advanceTombstone( + tombstone( + sequence = 1L, + state = MutationTombstoneState.SUPERSEDED, + activatedAt = 20L, + supersededBySequence = 9L, + supersededAt = 30L, + ), + ) + } + assertFailsWith { + storage.transaction { transaction -> + transaction.advanceTombstone( + tombstone( + sequence = 1L, + state = MutationTombstoneState.ACTIVE, + activatedAt = 20L, + ), + ) + } + } + val persisted = storage.transaction { it.tombstones().single() } + assertEquals(MutationTombstoneState.SUPERSEDED, persisted.state) + assertEquals(9L, persisted.supersededBySequence) + + val guardedLowerSuccessorStorage = createStorage() + guardedLowerSuccessorStorage.transaction { transaction -> + transaction.insertTombstone( + tombstone( + sequence = 3L, + canonicalId = "guarded-lower-successor", + ), + ) + transaction.advanceTombstone( + tombstone( + sequence = 3L, + canonicalId = "guarded-lower-successor", + state = MutationTombstoneState.ACTIVE, + activatedAt = 40L, + ), + ) + } + val unguardedLowerSuccessor = + tombstone( + sequence = 3L, + canonicalId = "guarded-lower-successor", + state = MutationTombstoneState.SUPERSEDED, + activatedAt = 40L, + supersededByClientId = CLIENT_ID, + supersededBySequence = 1L, + supersededAt = 50L, + ) + assertFailsWith { + guardedLowerSuccessorStorage.transaction { transaction -> + transaction.advanceTombstone(unguardedLowerSuccessor) + } + } + val guardedPredecessor = guardedLowerSuccessorStorage.transaction { it.tombstones().single() } + assertEquals(MutationTombstoneState.ACTIVE, guardedPredecessor.state) + assertEquals(40L, guardedPredecessor.activatedAt) + assertNull(guardedPredecessor.supersededByClientId) + assertNull(guardedPredecessor.supersededBySequence) + assertNull(guardedPredecessor.supersededAt) + } + + @Test + public fun prune_stopsAtServerConfirmedPrefix(): TestResult = runTest { + val storage = createStorage() + appendIntent(storage, sequence = 1L) + appendIntent(storage, sequence = 2L) + appendIntent(storage, sequence = 3L) + retireForPrune(storage, sequence = 1L, retiredThrough = 1L) + retireForPrune(storage, sequence = 2L, retiredThrough = 2L) + storage.transaction { transaction -> + transaction.insertTombstone( + tombstone(sequence = 1L, canonicalId = "obsolete"), + ) + transaction.advanceTombstone( + tombstone( + sequence = 1L, + canonicalId = "obsolete", + state = MutationTombstoneState.ACTIVE, + activatedAt = 60L, + ), + ) + transaction.advanceTombstone( + tombstone( + sequence = 1L, + canonicalId = "obsolete", + state = MutationTombstoneState.SUPERSEDED, + activatedAt = 60L, + supersededBySequence = 2L, + supersededAt = 61L, + ), + ) + transaction.confirmRetiredThrough( + clientId = CLIENT_ID, + requestedThroughSequence = 2L, + serverConfirmedThroughSequence = 2L, + ) + } + + assertFailsWith { + storage.transaction { it.prune(CLIENT_ID, serverConfirmedRetiredThroughSequence = 3L) } + } + storage.transaction { it.prune(CLIENT_ID, serverConfirmedRetiredThroughSequence = 2L) } + + assertFailsWith { + storage.transaction { transaction -> + insertIntent( + transaction = transaction, + sequence = 1L, + mutationId = "mutation-reused-after-prune", + ) + } + } + + storage.transaction { transaction -> + assertEquals(listOf(3L), transaction.intents(CLIENT_ID).map { it.clientSequence }) + assertEquals(listOf(3L), transaction.executions(CLIENT_ID).map { it.clientSequence }) + assertTrue(transaction.attempts(CLIENT_ID).isEmpty()) + assertTrue(transaction.acks(CLIENT_ID).isEmpty()) + assertTrue(transaction.failures(CLIENT_ID).isEmpty()) + assertTrue(transaction.effects(CLIENT_ID).isEmpty()) + assertTrue(transaction.tombstones().none { it.canonicalId == "obsolete" }) + assertEquals(2L, requireNotNull(transaction.client(CLIENT_ID)).serverConfirmedRetiredThroughSequence) + } + } + + @Test + public fun prune_preservesAliasesAndActiveTombstoneGeneration(): TestResult = runTest { + val storage = createStorage() + appendIntent(storage, sequence = 1L) + storage.transaction { transaction -> + transaction.insertAlias(alias()) + transaction.advanceAlias( + alias(state = MutationAliasState.ACTIVE, activatedAt = 10L), + ) + transaction.insertTombstone( + tombstone(sequence = 1L, canonicalId = "protected-active"), + ) + transaction.advanceTombstone( + tombstone( + sequence = 1L, + canonicalId = "protected-active", + state = MutationTombstoneState.ACTIVE, + activatedAt = 11L, + ), + ) + transaction.insertTombstone( + tombstone(sequence = 1L, canonicalId = "protected-pending"), + ) + } + retireForPrune(storage, sequence = 1L, retiredThrough = 1L) + storage.transaction { transaction -> + transaction.confirmRetiredThrough( + clientId = CLIENT_ID, + requestedThroughSequence = 1L, + serverConfirmedThroughSequence = 1L, + ) + transaction.prune(CLIENT_ID, serverConfirmedRetiredThroughSequence = 1L) + } + + storage.transaction { transaction -> + assertTrue(transaction.intents(CLIENT_ID).isEmpty()) + assertTrue(transaction.executions(CLIENT_ID).isEmpty()) + assertEquals(MutationAliasState.ACTIVE, transaction.aliases().single().state) + assertEquals( + setOf(MutationTombstoneState.ACTIVE, MutationTombstoneState.PENDING), + transaction.tombstones().map { it.state }.toSet(), + ) + } + } + + @Test + public fun blobs_areCopiedOnEntryAndDelivery(): TestResult = runTest { + val storage = createStorage() + val args = byteArrayOf(1, 2, 3) + val base = byteArrayOf(4, 5) + val mine = byteArrayOf(6) + val authoritative = byteArrayOf(7, 8) + storage.transaction { transaction -> + transaction.insertClient(client()) + transaction.advanceClient(client(lastAllocated = 1L)) + insertIntent(transaction, sequence = 1L, args = args) + transaction.insertExecution(execution(sequence = 1L)) + transaction.insertAttempt( + attempt(sequence = 1L, generation = 1, base = base, mine = mine), + ) + transaction.insertAck( + ack(sequence = 1L, generation = 1, authoritative = authoritative), + ) + } + args[0] = 99 + base[0] = 99 + mine[0] = 99 + authoritative[0] = 99 + + storage.transaction { transaction -> + val intentBlob = transaction.intents(CLIENT_ID).single().argsBlob + val persistedAttempt = transaction.attempts(CLIENT_ID).single() + val ackBlob = requireNotNull(transaction.acks(CLIENT_ID).single().authoritativeBlob) + assertContentEquals(byteArrayOf(1, 2, 3), intentBlob) + assertContentEquals(byteArrayOf(4, 5), persistedAttempt.baseBlob) + assertContentEquals(byteArrayOf(6), persistedAttempt.mineBlob) + assertContentEquals(byteArrayOf(7, 8), ackBlob) + intentBlob[0] = 88 + requireNotNull(persistedAttempt.baseBlob)[0] = 88 + requireNotNull(persistedAttempt.mineBlob)[0] = 88 + ackBlob[0] = 88 + } + storage.transaction { transaction -> + assertContentEquals(byteArrayOf(1, 2, 3), transaction.intents(CLIENT_ID).single().argsBlob) + assertContentEquals(byteArrayOf(4, 5), transaction.attempts(CLIENT_ID).single().baseBlob) + assertContentEquals(byteArrayOf(6), transaction.attempts(CLIENT_ID).single().mineBlob) + assertContentEquals(byteArrayOf(7, 8), transaction.acks(CLIENT_ID).single().authoritativeBlob) + } + } + + @Test + public fun reopen_roundTripsClientRecord(): TestResult = runTest { + val storage = createStorage() + storage.transaction { transaction -> + transaction.insertClient(client()) + transaction.advanceClient( + client(lastAllocated = 4L, retiredThrough = 3L), + ) + transaction.confirmRetiredThrough( + clientId = CLIENT_ID, + requestedThroughSequence = 2L, + serverConfirmedThroughSequence = 2L, + ) + } + + val reopened = reopenStorage(storage) + val persisted = reopened.transaction { it.client(CLIENT_ID) } + assertNotNull(persisted) + assertEquals(1, persisted.recordVersion) + assertEquals(4L, persisted.lastAllocatedSequence) + assertEquals(3L, persisted.retiredThroughSequence) + assertEquals(2L, persisted.serverConfirmedRetiredThroughSequence) + assertEquals(1L, persisted.createdAt) + } + + @Test + public fun reopen_roundTripsIntentAndExecutionRecords(): TestResult = runTest { + val storage = createStorage() + val inserted = appendIntent(storage, sequence = 1L) + storage.transaction { transaction -> + transaction.insertAttempt(attempt(sequence = 1L, generation = 1)) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.READY, + generation = 1, + ), + ) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.INFLIGHT, + generation = 1, + ), + ) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.READY, + generation = 1, + attemptCount = 1, + lastAttemptAt = 30L, + ), + ) + } + + val reopened = reopenStorage(storage) + reopened.transaction { transaction -> + val persistedIntent = transaction.intents(CLIENT_ID).single() + assertEquals(inserted.rowId, persistedIntent.rowId) + assertEquals(1, persistedIntent.recordVersion) + assertEquals(CLIENT_ID, persistedIntent.clientId) + assertEquals(1L, persistedIntent.clientSequence) + assertEquals("mutation-1", persistedIntent.mutationId) + assertEquals("items", persistedIntent.namespace) + assertEquals("item-1", persistedIntent.canonicalId) + assertEquals("mutator", persistedIntent.mutatorId) + assertEquals(1, persistedIntent.mutatorVersion) + assertContentEquals(byteArrayOf(1, 2, 3), persistedIntent.argsBlob) + assertEquals("root-1", persistedIntent.idempotencyRoot) + assertEquals(11L, persistedIntent.createdAt) + val persistedExecution = transaction.executions(CLIENT_ID).single() + assertEquals(CLIENT_ID, persistedExecution.clientId) + assertEquals(1L, persistedExecution.clientSequence) + assertEquals(MutationExecutionPhase.READY, persistedExecution.phase) + assertEquals(1, persistedExecution.currentGeneration) + assertEquals(1, persistedExecution.attempt) + assertEquals(30L, persistedExecution.lastAttemptAt) + assertNull(persistedExecution.activeFailureId) + assertNull(persistedExecution.retiredAt) + } + } + + @Test + public fun reopen_roundTripsAttemptAndAckRecords(): TestResult = runTest { + val storage = createStorage() + appendIntent(storage, sequence = 1L) + storage.transaction { transaction -> + transaction.insertAttempt( + attempt( + sequence = 1L, + generation = 1, + preconditionMetaPresent = true, + preconditionWrittenAt = 19L, + preconditionEtag = "before-etag", + advertisedRetiredThroughSequence = 0L, + ), + ) + transaction.recordConflictReceipt( + attempt( + sequence = 1L, + generation = 1, + preconditionMetaPresent = true, + preconditionWrittenAt = 19L, + preconditionEtag = "before-etag", + advertisedRetiredThroughSequence = 0L, + conflictMetaPresent = true, + conflictWrittenAt = 29L, + conflictEtag = "conflict-etag", + conflictReceivedAt = 30L, + ), + ) + transaction.insertAck(ack(sequence = 1L, generation = 1)) + } + + val reopened = reopenStorage(storage) + reopened.transaction { transaction -> + val persistedAttempt = transaction.attempts(CLIENT_ID).single() + assertEquals(CLIENT_ID, persistedAttempt.clientId) + assertEquals(1L, persistedAttempt.clientSequence) + assertEquals(1, persistedAttempt.generation) + assertEquals("items", persistedAttempt.effectiveNamespace) + assertEquals("item-1", persistedAttempt.effectiveCanonicalId) + assertEquals(1, persistedAttempt.valueCodecVersion) + assertEquals(MutationPresenceState.PRESENT, persistedAttempt.basePresence) + assertEquals(MutationPresenceState.PRESENT, persistedAttempt.minePresence) + assertEquals("generation-1-1", persistedAttempt.generationIdempotencyKey) + assertContentEquals(byteArrayOf(4), persistedAttempt.baseBlob) + assertContentEquals(byteArrayOf(6), persistedAttempt.mineBlob) + assertTrue(persistedAttempt.preconditionMetaPresent) + assertEquals(19L, persistedAttempt.preconditionWrittenAt) + assertEquals("before-etag", persistedAttempt.preconditionEtag) + assertEquals(0L, persistedAttempt.advertisedRetiredThroughSequence) + assertEquals(21L, persistedAttempt.preparedAt) + assertEquals(true, persistedAttempt.conflictMetaPresent) + assertEquals(29L, persistedAttempt.conflictWrittenAt) + assertEquals("conflict-etag", persistedAttempt.conflictEtag) + assertEquals(30L, persistedAttempt.conflictReceivedAt) + val persistedAck = transaction.acks(CLIENT_ID).single() + assertEquals(CLIENT_ID, persistedAck.clientId) + assertEquals(1L, persistedAck.clientSequence) + assertEquals(1, persistedAck.generation) + assertEquals(MutationPresenceState.PRESENT, persistedAck.authoritativePresence) + assertContentEquals(byteArrayOf(7), persistedAck.authoritativeBlob) + assertEquals(1, persistedAck.valueCodecVersion) + assertEquals("etag", persistedAck.etag) + assertEquals("items", persistedAck.canonicalTargetNamespace) + assertEquals("canonical-1", persistedAck.canonicalTargetId) + assertEquals(50L, persistedAck.receivedAt) + } + } + + @Test + public fun reopen_roundTripsFailureAndEffectRecords(): TestResult = runTest { + val storage = createStorage() + appendIntent(storage, sequence = 1L) + val insertedFailure = + storage.transaction { transaction -> + val failure = transaction.appendFailure( + clientId = CLIENT_ID, + clientSequence = 1L, + generation = 0, + kind = MutationFailureKind.TRANSPORT, + detail = "offline", + message = "network unavailable", + occurredAt = 12L, + ) + transaction.insertEffect(effect(sequence = 1L, index = 0)) + failure + } + + val reopened = reopenStorage(storage) + reopened.transaction { transaction -> + val failure = transaction.failures(CLIENT_ID).single() + assertEquals(insertedFailure.failureId, failure.failureId) + assertEquals(CLIENT_ID, failure.clientId) + assertEquals(1L, failure.clientSequence) + assertEquals(0, failure.generation) + assertEquals(MutationFailureKind.TRANSPORT, failure.kind) + assertEquals("offline", failure.detail) + assertEquals("network unavailable", failure.message) + assertEquals(12L, failure.occurredAt) + val persistedEffect = transaction.effects(CLIENT_ID).single() + assertEquals(CLIENT_ID, persistedEffect.clientId) + assertEquals(1L, persistedEffect.clientSequence) + assertEquals(0, persistedEffect.effectIndex) + assertEquals(MutationEffectKind.KEY, persistedEffect.kind) + assertEquals("effects", persistedEffect.namespace) + assertEquals("target-0", persistedEffect.canonicalId) + assertEquals(20L, persistedEffect.createdAt) + assertEquals(MutationEffectDisposition.PENDING, persistedEffect.disposition) + assertNull(persistedEffect.completedAt) + } + } + + @Test + public fun reopen_roundTripsAliasAndTombstoneRecords(): TestResult = runTest { + val storage = createStorage() + storage.transaction { transaction -> + transaction.insertAlias(alias()) + transaction.insertTombstone(tombstone()) + } + + val reopened = reopenStorage(storage) + reopened.transaction { transaction -> + val persistedAlias = transaction.aliases().single() + assertEquals("items", persistedAlias.sourceNamespace) + assertEquals("item-1", persistedAlias.sourceCanonicalId) + assertEquals("items", persistedAlias.targetNamespace) + assertEquals("canonical-1", persistedAlias.targetCanonicalId) + assertEquals(MutationAliasState.PENDING, persistedAlias.state) + assertEquals(CLIENT_ID, persistedAlias.createdByClientId) + assertEquals(1L, persistedAlias.createdBySequence) + assertEquals(10L, persistedAlias.createdAt) + assertNull(persistedAlias.activatedAt) + val persistedTombstone = transaction.tombstones().single() + assertEquals("items", persistedTombstone.namespace) + assertEquals("item-1", persistedTombstone.canonicalId) + assertEquals(CLIENT_ID, persistedTombstone.createdByClientId) + assertEquals(1L, persistedTombstone.createdBySequence) + assertEquals(MutationTombstoneState.PENDING, persistedTombstone.state) + assertEquals(10L, persistedTombstone.createdAt) + assertNull(persistedTombstone.activatedAt) + assertNull(persistedTombstone.supersededByClientId) + assertNull(persistedTombstone.supersededBySequence) + assertNull(persistedTombstone.supersededAt) + } + } + + @Test + public fun preparation_attemptEffectsAndReadyCommitAtomically(): TestResult = runTest { + val storage = createStorage() + appendIntent(storage, sequence = 1L) + val failure = Rollback() + val caught = + assertFailsWith { + storage.transaction { transaction -> + transaction.insertAttempt(attempt(sequence = 1L, generation = 1)) + transaction.insertEffect(effect(sequence = 1L, index = 0)) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.READY, + generation = 1, + ), + ) + throw failure + } + } + assertSame(failure, caught) + storage.transaction { transaction -> + assertTrue(transaction.attempts(CLIENT_ID).isEmpty()) + assertTrue(transaction.effects(CLIENT_ID).isEmpty()) + assertEquals(MutationExecutionPhase.UNPREPARED, transaction.executions(CLIENT_ID).single().phase) + } + + storage.transaction { transaction -> + transaction.insertAttempt(attempt(sequence = 1L, generation = 1)) + transaction.insertEffect(effect(sequence = 1L, index = 0)) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.READY, + generation = 1, + ), + ) + } + storage.transaction { transaction -> + assertEquals(1, transaction.attempts(CLIENT_ID).size) + assertEquals(1, transaction.effects(CLIENT_ID).size) + assertEquals(MutationExecutionPhase.READY, transaction.executions(CLIENT_ID).single().phase) + } + } + + @Test + public fun serverWins_skipRetireAndPrefixCommitAtomically(): TestResult = runTest { + val storage = createStorage() + appendIntent(storage, sequence = 1L) + prepareConflict(storage, sequence = 1L) + val failure = Rollback() + assertFailsWith { + storage.transaction { transaction -> + transaction.advanceEffect( + effect( + sequence = 1L, + index = 0, + disposition = MutationEffectDisposition.SKIPPED, + completedAt = 50L, + ), + ) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.RETIRED, + generation = 1, + attemptCount = 1, + lastAttemptAt = 40L, + retiredAt = 50L, + ), + ) + transaction.advanceClient( + client(lastAllocated = 1L, retiredThrough = 1L), + ) + throw failure + } + } + storage.transaction { transaction -> + assertEquals(MutationEffectDisposition.PENDING, transaction.effects(CLIENT_ID).single().disposition) + assertEquals(MutationExecutionPhase.REFRESH_REQUIRED, transaction.executions(CLIENT_ID).single().phase) + assertEquals(0L, requireNotNull(transaction.client(CLIENT_ID)).retiredThroughSequence) + } + + storage.transaction { transaction -> + transaction.advanceEffect( + effect( + sequence = 1L, + index = 0, + disposition = MutationEffectDisposition.SKIPPED, + completedAt = 50L, + ), + ) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.RETIRED, + generation = 1, + attemptCount = 1, + lastAttemptAt = 40L, + retiredAt = 50L, + ), + ) + transaction.advanceClient(client(lastAllocated = 1L, retiredThrough = 1L)) + } + storage.transaction { transaction -> + assertEquals(MutationEffectDisposition.SKIPPED, transaction.effects(CLIENT_ID).single().disposition) + assertEquals(MutationExecutionPhase.RETIRED, transaction.executions(CLIENT_ID).single().phase) + assertEquals(1L, requireNotNull(transaction.client(CLIENT_ID)).retiredThroughSequence) + } + } + + @Test + public fun ackReceipt_ackRoutingGenerationAndAckedCommitAtomically(): TestResult = runTest { + val versionedStorage = createStorage() + appendIntent(versionedStorage, sequence = 1L) + versionedStorage.transaction { transaction -> + transaction.insertAttempt( + attempt( + sequence = 1L, + generation = 1, + valueCodecVersion = 7, + ), + ) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.READY, + generation = 1, + ), + ) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.INFLIGHT, + generation = 1, + ), + ) + } + val currentVersionAck = + ack( + sequence = 1L, + generation = 1, + authoritative = byteArrayOf(9, 7), + valueCodecVersion = 9, + ) + assertFailsWith { + versionedStorage.transaction { transaction -> + transaction.insertAck(currentVersionAck) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.ACKED, + generation = 1, + attemptCount = 1, + lastAttemptAt = 50L, + ), + ) + throw Rollback() + } + } + versionedStorage.transaction { transaction -> + assertTrue(transaction.acks(CLIENT_ID).isEmpty()) + assertEquals(MutationExecutionPhase.INFLIGHT, transaction.executions(CLIENT_ID).single().phase) + } + + versionedStorage.transaction { transaction -> + transaction.insertAck(currentVersionAck) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.ACKED, + generation = 1, + attemptCount = 1, + lastAttemptAt = 50L, + ), + ) + } + versionedStorage.transaction { transaction -> + transaction.insertAck(currentVersionAck) + } + assertFailsWith { + versionedStorage.transaction { transaction -> + transaction.insertAck( + ack( + sequence = 1L, + generation = 1, + authoritative = byteArrayOf(9, 8), + valueCodecVersion = 9, + ), + ) + } + } + reopenStorage(versionedStorage).transaction { transaction -> + val persisted = transaction.acks(CLIENT_ID).single() + assertEquals(9, persisted.valueCodecVersion) + assertContentEquals(byteArrayOf(9, 7), persisted.authoritativeBlob) + assertEquals(MutationExecutionPhase.ACKED, transaction.executions(CLIENT_ID).single().phase) + } + + val aliasStorage = createStorage() + appendIntent(aliasStorage, sequence = 1L) + prepareForPush(aliasStorage, sequence = 1L) + assertFailsWith { + aliasStorage.transaction { transaction -> + transaction.insertAck(ack(sequence = 1L, generation = 1)) + transaction.insertAlias(alias(sequence = 1L)) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.ACKED, + generation = 1, + attemptCount = 1, + lastAttemptAt = 50L, + ), + ) + throw Rollback() + } + } + aliasStorage.transaction { transaction -> + assertTrue(transaction.acks(CLIENT_ID).isEmpty()) + assertTrue(transaction.aliases().isEmpty()) + assertEquals(MutationExecutionPhase.INFLIGHT, transaction.executions(CLIENT_ID).single().phase) + } + + aliasStorage.transaction { transaction -> + transaction.insertAck(ack(sequence = 1L, generation = 1)) + transaction.insertAlias(alias(sequence = 1L)) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.ACKED, + generation = 1, + attemptCount = 1, + lastAttemptAt = 50L, + ), + ) + } + aliasStorage.transaction { transaction -> + assertEquals(1, transaction.acks(CLIENT_ID).size) + assertEquals(MutationAliasState.PENDING, transaction.aliases().single().state) + assertEquals(MutationExecutionPhase.ACKED, transaction.executions(CLIENT_ID).single().phase) + } + + val tombstoneStorage = createStorage() + appendIntent(tombstoneStorage, sequence = 1L) + prepareForPush(tombstoneStorage, sequence = 1L) + assertFailsWith { + tombstoneStorage.transaction { transaction -> + transaction.insertAck(absentAck(sequence = 1L, generation = 1)) + transaction.insertTombstone(tombstone(sequence = 1L)) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.ACKED, + generation = 1, + attemptCount = 1, + lastAttemptAt = 50L, + ), + ) + throw Rollback() + } + } + tombstoneStorage.transaction { transaction -> + assertTrue(transaction.acks(CLIENT_ID).isEmpty()) + assertTrue(transaction.tombstones().isEmpty()) + assertEquals(MutationExecutionPhase.INFLIGHT, transaction.executions(CLIENT_ID).single().phase) + } + tombstoneStorage.transaction { transaction -> + transaction.insertAck(absentAck(sequence = 1L, generation = 1)) + transaction.insertTombstone(tombstone(sequence = 1L)) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.ACKED, + generation = 1, + attemptCount = 1, + lastAttemptAt = 50L, + ), + ) + } + tombstoneStorage.transaction { transaction -> + assertEquals(MutationPresenceState.ABSENT, transaction.acks(CLIENT_ID).single().authoritativePresence) + assertEquals(MutationTombstoneState.PENDING, transaction.tombstones().single().state) + assertEquals(MutationExecutionPhase.ACKED, transaction.executions(CLIENT_ID).single().phase) + } + } + + @Test + public fun adoptionAdvance_commitsAtomically(): TestResult = runTest { + val storage = createStorage() + appendIntent(storage, sequence = 1L) + acknowledge(storage, sequence = 1L) + assertFailsWith { + storage.transaction { transaction -> + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.EFFECTS_PENDING, + generation = 1, + attemptCount = 1, + lastAttemptAt = 50L, + ), + ) + throw Rollback() + } + } + assertEquals( + MutationExecutionPhase.ACKED, + storage.transaction { it.executions(CLIENT_ID).single().phase }, + ) + + storage.transaction { transaction -> + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.EFFECTS_PENDING, + generation = 1, + attemptCount = 1, + lastAttemptAt = 50L, + ), + ) + } + assertEquals( + MutationExecutionPhase.EFFECTS_PENDING, + storage.transaction { it.executions(CLIENT_ID).single().phase }, + ) + } + + @Test + public fun retirementFinalization_highWaterAndRoutingCommitAtomically(): TestResult = runTest { + val aliasStorage = createStorage() + appendIntent(aliasStorage, sequence = 1L) + prepareForPush(aliasStorage, sequence = 1L, includeEffect = true) + aliasStorage.transaction { transaction -> + transaction.insertAck(ack(sequence = 1L, generation = 1)) + transaction.insertAlias(alias(sequence = 1L)) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.ACKED, + generation = 1, + attemptCount = 1, + lastAttemptAt = 50L, + ), + ) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.EFFECTS_PENDING, + generation = 1, + attemptCount = 1, + lastAttemptAt = 50L, + ), + ) + transaction.advanceEffect( + effect( + sequence = 1L, + index = 0, + disposition = MutationEffectDisposition.APPLIED, + completedAt = 60L, + ), + ) + } + + assertFailsWith { + aliasStorage.transaction { transaction -> + finalizeAliasRetirement(transaction) + throw Rollback() + } + } + aliasStorage.transaction { transaction -> + assertEquals(MutationExecutionPhase.EFFECTS_PENDING, transaction.executions(CLIENT_ID).single().phase) + assertEquals(0L, requireNotNull(transaction.client(CLIENT_ID)).retiredThroughSequence) + assertEquals(MutationAliasState.PENDING, transaction.aliases().single().state) + } + + aliasStorage.transaction(::finalizeAliasRetirement) + aliasStorage.transaction { transaction -> + assertEquals(MutationExecutionPhase.RETIRED, transaction.executions(CLIENT_ID).single().phase) + assertEquals(1L, requireNotNull(transaction.client(CLIENT_ID)).retiredThroughSequence) + assertEquals(MutationAliasState.ACTIVE, transaction.aliases().single().state) + } + + val tombstoneStorage = createStorage() + appendIntent(tombstoneStorage, sequence = 1L) + prepareForPush(tombstoneStorage, sequence = 1L, includeEffect = true) + tombstoneStorage.transaction { transaction -> + transaction.insertAck(absentAck(sequence = 1L, generation = 1)) + transaction.insertTombstone(tombstone(createdByClientId = OLDER_CLIENT_ID)) + transaction.advanceTombstone( + tombstone( + createdByClientId = OLDER_CLIENT_ID, + state = MutationTombstoneState.ACTIVE, + activatedAt = 5L, + ), + ) + transaction.insertTombstone(tombstone(sequence = 1L)) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.ACKED, + generation = 1, + attemptCount = 1, + lastAttemptAt = 50L, + ), + ) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.EFFECTS_PENDING, + generation = 1, + attemptCount = 1, + lastAttemptAt = 50L, + ), + ) + transaction.advanceEffect( + effect( + sequence = 1L, + index = 0, + disposition = MutationEffectDisposition.APPLIED, + completedAt = 60L, + ), + ) + } + assertFailsWith { + tombstoneStorage.transaction { transaction -> + finalizeTombstoneRetirement(transaction) + throw Rollback() + } + } + tombstoneStorage.transaction { transaction -> + assertEquals(MutationExecutionPhase.EFFECTS_PENDING, transaction.executions(CLIENT_ID).single().phase) + assertEquals(0L, requireNotNull(transaction.client(CLIENT_ID)).retiredThroughSequence) + val byCreator = transaction.tombstones().associateBy { it.createdByClientId } + assertEquals(MutationTombstoneState.ACTIVE, byCreator.getValue(OLDER_CLIENT_ID).state) + assertEquals(MutationTombstoneState.PENDING, byCreator.getValue(CLIENT_ID).state) + } + tombstoneStorage.transaction(::finalizeTombstoneRetirement) + tombstoneStorage.transaction { transaction -> + assertEquals(MutationExecutionPhase.RETIRED, transaction.executions(CLIENT_ID).single().phase) + assertEquals(1L, requireNotNull(transaction.client(CLIENT_ID)).retiredThroughSequence) + val byCreator = transaction.tombstones().associateBy { it.createdByClientId } + assertEquals(MutationTombstoneState.SUPERSEDED, byCreator.getValue(OLDER_CLIENT_ID).state) + assertEquals(MutationTombstoneState.ACTIVE, byCreator.getValue(CLIENT_ID).state) + } + + CausalFinalizationFault.entries.forEach { fault -> + val candidate = causalLowerSuccessorStorage(fault) + val before = causalFinalizationSnapshot(candidate) + assertFailsWith(fault.name) { + candidate.transaction { transaction -> + finalizeCausalLowerSuccessor(transaction, fault) + } + } + assertEquals(before, causalFinalizationSnapshot(candidate), fault.name) + } + + val causalStorage = causalLowerSuccessorStorage(fault = null) + causalStorage.transaction { transaction -> + finalizeCausalLowerSuccessor(transaction, fault = null) + } + val causalFinal = causalFinalizationSnapshot(causalStorage) + assertEquals(3L, causalFinal.retiredThroughSequence) + assertEquals( + MutationExecutionPhase.RETIRED, + causalFinal.executions.single { it.sequence == 1L }.phase, + ) + assertEquals( + MutationAliasState.ACTIVE, + causalFinal.aliases.single { it.sourceCanonicalId == CAUSAL_SOURCE_ID }.state, + ) + val supersededCausalTombstones = + causalFinal.tombstones + .filter { it.canonicalId == CAUSAL_MIDDLE_ID || it.canonicalId == CAUSAL_TARGET_ID } + assertEquals(2, supersededCausalTombstones.size) + supersededCausalTombstones.forEach { tombstone -> + assertEquals(MutationTombstoneState.SUPERSEDED, tombstone.state) + assertEquals(CLIENT_ID, tombstone.supersededByClientId) + assertEquals(1L, tombstone.supersededBySequence) + assertEquals( + if (tombstone.canonicalId == CAUSAL_MIDDLE_ID) 60L else 61L, + tombstone.activatedAt, + ) + } + + appendIntent( + storage = causalStorage, + sequence = 4L, + canonicalId = "post-causal-release", + ) + causalStorage.transaction { transaction -> + transaction.insertAttempt( + attempt( + sequence = 4L, + generation = 1, + effectiveCanonicalId = "post-causal-release", + ), + ) + transaction.advanceExecution( + execution( + sequence = 4L, + phase = MutationExecutionPhase.READY, + generation = 1, + ), + ) + transaction.advanceExecution( + execution( + sequence = 4L, + phase = MutationExecutionPhase.INFLIGHT, + generation = 1, + ), + ) + } + assertEquals( + MutationExecutionPhase.INFLIGHT, + causalStorage.transaction { transaction -> + transaction.executions(CLIENT_ID).single { it.clientSequence == 4L }.phase + }, + ) + } + + @Test + public fun parking_failurePhaseAndActiveFailureCommitAtomically(): TestResult = runTest { + val storage = createStorage() + appendIntent(storage, sequence = 1L) + val failure = Rollback() + assertFailsWith { + storage.transaction { transaction -> + val record = appendParkedFailure(transaction) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.PARKED, + activeFailureId = record.failureId, + ), + ) + throw failure + } + } + storage.transaction { transaction -> + assertTrue(transaction.failures(CLIENT_ID).isEmpty()) + assertEquals(MutationExecutionPhase.UNPREPARED, transaction.executions(CLIENT_ID).single().phase) + } + + storage.transaction { transaction -> + val record = appendParkedFailure(transaction) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.PARKED, + activeFailureId = record.failureId, + ), + ) + } + storage.transaction { transaction -> + val record = transaction.failures(CLIENT_ID).single() + val execution = transaction.executions(CLIENT_ID).single() + assertEquals(MutationExecutionPhase.PARKED, execution.phase) + assertEquals(record.failureId, execution.activeFailureId) + } + } + + private enum class CausalFinalizationFault { + PREDECESSOR_NOT_ACTIVE, + SUCCESSOR_NOT_EFFECTS_PENDING, + ACK_NOT_PRESENT, + ACK_DOES_NOT_OWN_ALIAS, + EFFECT_PENDING, + ALIAS_NOT_ACTIVATED, + TERMINAL_MISSES_PREDECESSOR, + SUCCESSOR_NOT_RETIRED, + PREFIX_NOT_ADVANCED, + COLLAPSED_TOMBSTONE_NOT_SUPERSEDED, + ACTIVATION_TIMESTAMP_CHANGED, + SUCCESSOR_IDENTITY_MISMATCH, + SUCCESSOR_CLIENT_MISMATCH, + } + + private data class CausalExecutionSnapshot( + val sequence: Long, + val phase: MutationExecutionPhase, + val attempt: Int, + val lastAttemptAt: Long?, + val retiredAt: Long?, + ) + + private data class CausalAliasSnapshot( + val sourceCanonicalId: String, + val targetCanonicalId: String, + val state: MutationAliasState, + val createdBySequence: Long, + val activatedAt: Long?, + ) + + private data class CausalTombstoneSnapshot( + val canonicalId: String, + val state: MutationTombstoneState, + val activatedAt: Long?, + val supersededByClientId: String?, + val supersededBySequence: Long?, + val supersededAt: Long?, + ) + + private data class CausalEffectSnapshot( + val index: Int, + val disposition: MutationEffectDisposition, + val completedAt: Long?, + ) + + private data class CausalFinalizationSnapshot( + val retiredThroughSequence: Long, + val executions: List, + val aliases: List, + val tombstones: List, + val effects: List, + ) + + private suspend fun causalLowerSuccessorStorage( + fault: CausalFinalizationFault?, + ): MutationJournalStorage = + createStorage().also { storage -> + storage.transaction { transaction -> + transaction.insertClient(client()) + transaction.advanceClient(client(lastAllocated = 3L)) + listOf( + Triple(1L, CAUSAL_SOURCE_ID, "source-mutation"), + Triple(2L, CAUSAL_MIDDLE_ID, "middle-mutation"), + Triple(3L, CAUSAL_TARGET_ID, "target-mutation"), + ).forEach { (sequence, canonicalId, mutationId) -> + insertIntent( + transaction = transaction, + sequence = sequence, + mutationId = mutationId, + canonicalId = canonicalId, + ) + transaction.insertExecution(execution(sequence = sequence)) + } + } + + storage.transaction { transaction -> + transaction.insertAttempt( + attempt( + sequence = 2L, + generation = 1, + effectiveCanonicalId = CAUSAL_MIDDLE_ID, + ), + ) + transaction.advanceExecution( + execution( + sequence = 2L, + phase = MutationExecutionPhase.READY, + generation = 1, + ), + ) + transaction.advanceExecution( + execution( + sequence = 2L, + phase = MutationExecutionPhase.INFLIGHT, + generation = 1, + ), + ) + transaction.insertAck(sameIdentityAck(sequence = 2L, generation = 1)) + transaction.advanceExecution( + execution( + sequence = 2L, + phase = MutationExecutionPhase.ACKED, + generation = 1, + attemptCount = 1, + lastAttemptAt = 32L, + ), + ) + transaction.advanceExecution( + execution( + sequence = 2L, + phase = MutationExecutionPhase.EFFECTS_PENDING, + generation = 1, + attemptCount = 1, + lastAttemptAt = 32L, + ), + ) + transaction.insertAlias( + alias( + sourceId = CAUSAL_MIDDLE_ID, + targetId = CAUSAL_TARGET_ID, + sequence = 2L, + ), + ) + transaction.advanceAlias( + alias( + sourceId = CAUSAL_MIDDLE_ID, + targetId = CAUSAL_TARGET_ID, + sequence = 2L, + state = MutationAliasState.ACTIVE, + activatedAt = 42L, + ), + ) + transaction.advanceExecution( + execution( + sequence = 2L, + phase = MutationExecutionPhase.RETIRED, + generation = 1, + attemptCount = 1, + lastAttemptAt = 32L, + retiredAt = 42L, + ), + ) + } + + storage.transaction { transaction -> + transaction.insertAttempt( + attempt( + sequence = 3L, + generation = 1, + effectiveCanonicalId = CAUSAL_TARGET_ID, + ), + ) + transaction.advanceExecution( + execution( + sequence = 3L, + phase = MutationExecutionPhase.READY, + generation = 1, + ), + ) + transaction.advanceExecution( + execution( + sequence = 3L, + phase = MutationExecutionPhase.INFLIGHT, + generation = 1, + ), + ) + transaction.insertAck(absentAck(sequence = 3L, generation = 1)) + listOf(CAUSAL_MIDDLE_ID, CAUSAL_TARGET_ID).forEachIndexed { index, canonicalId -> + transaction.insertTombstone( + tombstone( + sequence = 3L, + canonicalId = canonicalId, + createdAt = 50L + index, + ), + ) + if ( + canonicalId != CAUSAL_TARGET_ID || + fault != CausalFinalizationFault.PREDECESSOR_NOT_ACTIVE + ) { + transaction.advanceTombstone( + tombstone( + sequence = 3L, + canonicalId = canonicalId, + state = MutationTombstoneState.ACTIVE, + createdAt = 50L + index, + activatedAt = 60L + index, + ), + ) + } + } + transaction.advanceExecution( + execution( + sequence = 3L, + phase = MutationExecutionPhase.ACKED, + generation = 1, + attemptCount = 1, + lastAttemptAt = 33L, + ), + ) + transaction.advanceExecution( + execution( + sequence = 3L, + phase = MutationExecutionPhase.EFFECTS_PENDING, + generation = 1, + attemptCount = 1, + lastAttemptAt = 33L, + ), + ) + transaction.advanceExecution( + execution( + sequence = 3L, + phase = MutationExecutionPhase.RETIRED, + generation = 1, + attemptCount = 1, + lastAttemptAt = 33L, + retiredAt = 43L, + ), + ) + } + + storage.transaction { transaction -> + val aliasTarget = + if (fault == CausalFinalizationFault.TERMINAL_MISSES_PREDECESSOR) { + CAUSAL_UNRELATED_ID + } else { + CAUSAL_MIDDLE_ID + } + val acknowledgementTarget = + if (fault == CausalFinalizationFault.ACK_DOES_NOT_OWN_ALIAS) { + CAUSAL_UNRELATED_ID + } else { + aliasTarget + } + transaction.insertAttempt( + attempt( + sequence = 1L, + generation = 1, + effectiveCanonicalId = CAUSAL_SOURCE_ID, + ), + ) + transaction.insertEffect(effect(sequence = 1L, index = 0)) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.READY, + generation = 1, + ), + ) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.INFLIGHT, + generation = 1, + ), + ) + if (fault == CausalFinalizationFault.ACK_NOT_PRESENT) { + transaction.insertAck(absentAck(sequence = 1L, generation = 1)) + } else { + transaction.insertAck( + ack( + sequence = 1L, + generation = 1, + canonicalTargetId = acknowledgementTarget, + ), + ) + } + transaction.insertAlias( + alias( + sourceId = CAUSAL_SOURCE_ID, + targetId = aliasTarget, + sequence = 1L, + ), + ) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.ACKED, + generation = 1, + attemptCount = 1, + lastAttemptAt = 31L, + ), + ) + if (fault != CausalFinalizationFault.SUCCESSOR_NOT_EFFECTS_PENDING) { + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.EFFECTS_PENDING, + generation = 1, + attemptCount = 1, + lastAttemptAt = 31L, + ), + ) + } + if (fault != CausalFinalizationFault.EFFECT_PENDING) { + transaction.advanceEffect( + effect( + sequence = 1L, + index = 0, + disposition = MutationEffectDisposition.APPLIED, + completedAt = 65L, + ), + ) + } + } + } + + private fun finalizeCausalLowerSuccessor( + transaction: MutationJournalTransaction, + fault: CausalFinalizationFault?, + ) { + val aliasTarget = + transaction.aliases().single { it.sourceCanonicalId == CAUSAL_SOURCE_ID }.targetCanonicalId + if (fault != CausalFinalizationFault.ALIAS_NOT_ACTIVATED) { + transaction.advanceAlias( + alias( + sourceId = CAUSAL_SOURCE_ID, + targetId = aliasTarget, + sequence = 1L, + state = MutationAliasState.ACTIVE, + activatedAt = 70L, + ), + ) + } + if (fault != CausalFinalizationFault.SUCCESSOR_NOT_RETIRED) { + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.RETIRED, + generation = 1, + attemptCount = 1, + lastAttemptAt = 31L, + retiredAt = 70L, + ), + ) + } + if (fault != CausalFinalizationFault.PREFIX_NOT_ADVANCED) { + transaction.advanceClient(client(lastAllocated = 3L, retiredThrough = 3L)) + } + listOf(CAUSAL_TARGET_ID, CAUSAL_MIDDLE_ID).forEachIndexed { index, canonicalId -> + if ( + canonicalId != CAUSAL_MIDDLE_ID || + fault != CausalFinalizationFault.COLLAPSED_TOMBSTONE_NOT_SUPERSEDED + ) { + transaction.advanceTombstone( + tombstone( + sequence = 3L, + canonicalId = canonicalId, + state = MutationTombstoneState.SUPERSEDED, + createdAt = if (canonicalId == CAUSAL_MIDDLE_ID) 50L else 51L, + activatedAt = + when { + fault == CausalFinalizationFault.ACTIVATION_TIMESTAMP_CHANGED && + canonicalId == CAUSAL_TARGET_ID -> 999L + canonicalId == CAUSAL_MIDDLE_ID -> 60L + else -> 61L + }, + supersededByClientId = + if ( + fault == CausalFinalizationFault.SUCCESSOR_CLIENT_MISMATCH && + index == 0 + ) { + OLDER_CLIENT_ID + } else { + CLIENT_ID + }, + supersededBySequence = + if ( + fault == CausalFinalizationFault.SUCCESSOR_IDENTITY_MISMATCH && + index == 0 + ) { + 2L + } else { + 1L + }, + supersededAt = 70L, + ), + ) + } + } + } + + private suspend fun causalFinalizationSnapshot( + storage: MutationJournalStorage, + ): CausalFinalizationSnapshot = + storage.transaction { transaction -> + CausalFinalizationSnapshot( + retiredThroughSequence = + requireNotNull(transaction.client(CLIENT_ID)).retiredThroughSequence, + executions = + transaction.executions(CLIENT_ID).map { execution -> + CausalExecutionSnapshot( + sequence = execution.clientSequence, + phase = execution.phase, + attempt = execution.attempt, + lastAttemptAt = execution.lastAttemptAt, + retiredAt = execution.retiredAt, + ) + }, + aliases = + transaction.aliases().map { alias -> + CausalAliasSnapshot( + sourceCanonicalId = alias.sourceCanonicalId, + targetCanonicalId = alias.targetCanonicalId, + state = alias.state, + createdBySequence = alias.createdBySequence, + activatedAt = alias.activatedAt, + ) + }, + tombstones = + transaction.tombstones().map { tombstone -> + CausalTombstoneSnapshot( + canonicalId = tombstone.canonicalId, + state = tombstone.state, + activatedAt = tombstone.activatedAt, + supersededByClientId = tombstone.supersededByClientId, + supersededBySequence = tombstone.supersededBySequence, + supersededAt = tombstone.supersededAt, + ) + }, + effects = + transaction.effects(CLIENT_ID).map { effect -> + CausalEffectSnapshot( + index = effect.effectIndex, + disposition = effect.disposition, + completedAt = effect.completedAt, + ) + }, + ) + } + + private suspend fun appendIntent( + storage: MutationJournalStorage, + sequence: Long, + namespace: String = "items", + canonicalId: String = "item-$sequence", + ): MutationIntentRecord = + storage.transaction { transaction -> + val current = transaction.client(CLIENT_ID) + if (current == null) { + transaction.insertClient(client()) + transaction.advanceClient(client(lastAllocated = sequence)) + } else { + transaction.advanceClient( + client( + lastAllocated = sequence, + retiredThrough = current.retiredThroughSequence, + confirmedThrough = current.serverConfirmedRetiredThroughSequence, + ), + ) + } + val intent = + insertIntent( + transaction = transaction, + sequence = sequence, + namespace = namespace, + canonicalId = canonicalId, + ) + transaction.insertExecution(execution(sequence = sequence)) + intent + } + + private fun insertIntent( + transaction: MutationJournalTransaction, + sequence: Long, + mutationId: String = "mutation-$sequence", + args: ByteArray = byteArrayOf(1, 2, 3), + namespace: String = "items", + canonicalId: String = "item-$sequence", + ): MutationIntentRecord = + transaction.insertIntent( + recordVersion = 1, + clientId = CLIENT_ID, + clientSequence = sequence, + mutationId = mutationId, + namespace = namespace, + canonicalId = canonicalId, + mutatorId = "mutator", + mutatorVersion = 1, + argsBlob = args, + idempotencyRoot = "root-$sequence", + createdAt = 10L + sequence, + ) + + private suspend fun prepareForPush( + storage: MutationJournalStorage, + sequence: Long, + includeEffect: Boolean = false, + ) { + storage.transaction { transaction -> + transaction.insertAttempt(attempt(sequence = sequence, generation = 1)) + if (includeEffect) { + transaction.insertEffect(effect(sequence = sequence, index = 0)) + } + transaction.advanceExecution( + execution( + sequence = sequence, + phase = MutationExecutionPhase.READY, + generation = 1, + ), + ) + transaction.advanceExecution( + execution( + sequence = sequence, + phase = MutationExecutionPhase.INFLIGHT, + generation = 1, + ), + ) + } + } + + private suspend fun c8OwnerPredicateStorage(ownerState: C8OwnerState): MutationJournalStorage { + val storage = createStorage() + appendIntent(storage, sequence = 1L, canonicalId = "predicate-owner") + appendIntent(storage, sequence = 2L, canonicalId = "predicate-contender") + storage.transaction { transaction -> + listOf(1L, 2L).forEach { sequence -> + transaction.insertAttempt( + attempt( + sequence = sequence, + generation = 1, + effectiveCanonicalId = + if (sequence == 1L) "predicate-owner" else "predicate-contender", + ), + ) + transaction.advanceExecution( + execution( + sequence = sequence, + phase = MutationExecutionPhase.READY, + generation = 1, + ), + ) + } + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.INFLIGHT, + generation = 1, + ), + ) + + when (ownerState) { + C8OwnerState.INFLIGHT -> Unit + C8OwnerState.RETRY_READY -> + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.READY, + generation = 1, + attemptCount = 1, + lastAttemptAt = 30L, + ), + ) + C8OwnerState.REFRESH_REQUIRED, + C8OwnerState.NEXT_GENERATION_READY, + -> { + transaction.recordConflictReceipt( + attempt( + sequence = 1L, + generation = 1, + effectiveCanonicalId = "predicate-owner", + conflictMetaPresent = false, + conflictReceivedAt = 40L, + ), + ) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.REFRESH_REQUIRED, + generation = 1, + attemptCount = 1, + lastAttemptAt = 40L, + ), + ) + if (ownerState == C8OwnerState.NEXT_GENERATION_READY) { + transaction.insertAttempt( + attempt( + sequence = 1L, + generation = 2, + effectiveCanonicalId = "predicate-owner", + ), + ) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.READY, + generation = 2, + ), + ) + } + } + C8OwnerState.ACKED, + C8OwnerState.EFFECTS_PENDING, + -> { + transaction.insertAck(sameIdentityAck(sequence = 1L, generation = 1)) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.ACKED, + generation = 1, + attemptCount = 1, + lastAttemptAt = 50L, + ), + ) + if (ownerState == C8OwnerState.EFFECTS_PENDING) { + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.EFFECTS_PENDING, + generation = 1, + attemptCount = 1, + lastAttemptAt = 50L, + ), + ) + } + } + } + } + return storage + } + + private suspend fun prepareConflict( + storage: MutationJournalStorage, + sequence: Long, + ) { + storage.transaction { transaction -> + transaction.insertAttempt(attempt(sequence = sequence, generation = 1)) + transaction.insertEffect(effect(sequence = sequence, index = 0)) + transaction.advanceExecution( + execution( + sequence = sequence, + phase = MutationExecutionPhase.READY, + generation = 1, + ), + ) + transaction.advanceExecution( + execution( + sequence = sequence, + phase = MutationExecutionPhase.INFLIGHT, + generation = 1, + ), + ) + transaction.recordConflictReceipt( + attempt( + sequence = sequence, + generation = 1, + conflictMetaPresent = false, + conflictReceivedAt = 40L, + ), + ) + transaction.advanceExecution( + execution( + sequence = sequence, + phase = MutationExecutionPhase.REFRESH_REQUIRED, + generation = 1, + attemptCount = 1, + lastAttemptAt = 40L, + ), + ) + } + } + + private suspend fun acknowledge( + storage: MutationJournalStorage, + sequence: Long, + ) { + prepareForPush(storage, sequence) + storage.transaction { transaction -> + transaction.insertAck(ack(sequence = sequence, generation = 1)) + transaction.advanceExecution( + execution( + sequence = sequence, + phase = MutationExecutionPhase.ACKED, + generation = 1, + attemptCount = 1, + lastAttemptAt = 50L, + ), + ) + } + } + + private suspend fun retireForPrune( + storage: MutationJournalStorage, + sequence: Long, + retiredThrough: Long, + ) { + prepareForPush(storage, sequence, includeEffect = true) + storage.transaction { transaction -> + transaction.insertAck(sameIdentityAck(sequence = sequence, generation = 1)) + transaction.appendFailure( + clientId = CLIENT_ID, + clientSequence = sequence, + generation = 1, + kind = MutationFailureKind.EFFECT, + detail = "retryable-effect", + message = "retained retryable evidence", + occurredAt = 45L + sequence, + ) + transaction.advanceExecution( + execution( + sequence = sequence, + phase = MutationExecutionPhase.ACKED, + generation = 1, + attemptCount = 1, + lastAttemptAt = 40L, + ), + ) + transaction.advanceExecution( + execution( + sequence = sequence, + phase = MutationExecutionPhase.EFFECTS_PENDING, + generation = 1, + attemptCount = 1, + lastAttemptAt = 40L, + ), + ) + transaction.advanceEffect( + effect( + sequence = sequence, + index = 0, + disposition = MutationEffectDisposition.APPLIED, + completedAt = 50L + sequence, + ), + ) + transaction.advanceExecution( + execution( + sequence = sequence, + phase = MutationExecutionPhase.RETIRED, + generation = 1, + attemptCount = 1, + lastAttemptAt = 40L, + retiredAt = 50L + sequence, + ), + ) + val current = requireNotNull(transaction.client(CLIENT_ID)) + transaction.advanceClient( + client( + lastAllocated = current.lastAllocatedSequence, + retiredThrough = retiredThrough, + confirmedThrough = current.serverConfirmedRetiredThroughSequence, + ), + ) + } + } + + private fun finalizeAliasRetirement(transaction: MutationJournalTransaction) { + transaction.advanceAlias( + alias( + sequence = 1L, + state = MutationAliasState.ACTIVE, + activatedAt = 70L, + ), + ) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.RETIRED, + generation = 1, + attemptCount = 1, + lastAttemptAt = 50L, + retiredAt = 70L, + ), + ) + transaction.advanceClient(client(lastAllocated = 1L, retiredThrough = 1L)) + } + + private fun finalizeTombstoneRetirement(transaction: MutationJournalTransaction) { + transaction.advanceTombstone( + tombstone( + createdByClientId = OLDER_CLIENT_ID, + state = MutationTombstoneState.SUPERSEDED, + activatedAt = 5L, + supersededByClientId = CLIENT_ID, + supersededBySequence = 1L, + supersededAt = 70L, + ), + ) + transaction.advanceTombstone( + tombstone( + sequence = 1L, + state = MutationTombstoneState.ACTIVE, + activatedAt = 70L, + ), + ) + transaction.advanceExecution( + execution( + sequence = 1L, + phase = MutationExecutionPhase.RETIRED, + generation = 1, + attemptCount = 1, + lastAttemptAt = 50L, + retiredAt = 70L, + ), + ) + transaction.advanceClient(client(lastAllocated = 1L, retiredThrough = 1L)) + } + + private fun appendParkedFailure(transaction: MutationJournalTransaction): MutationFailureRecord = + transaction.appendFailure( + clientId = CLIENT_ID, + clientSequence = 1L, + generation = 0, + kind = MutationFailureKind.CODEC, + detail = "unknown-mutator", + message = "mutator codec unavailable", + occurredAt = 80L, + ) + + private fun client( + lastAllocated: Long = 0L, + retiredThrough: Long = 0L, + confirmedThrough: Long = 0L, + ): MutationClientRecord = + MutationClientRecord( + recordVersion = 1, + clientId = CLIENT_ID, + lastAllocatedSequence = lastAllocated, + retiredThroughSequence = retiredThrough, + serverConfirmedRetiredThroughSequence = confirmedThrough, + createdAt = 1L, + ) + + private fun execution( + sequence: Long, + phase: MutationExecutionPhase = MutationExecutionPhase.UNPREPARED, + generation: Int = 0, + attemptCount: Int = 0, + lastAttemptAt: Long? = null, + activeFailureId: Long? = null, + retiredAt: Long? = null, + ): MutationExecutionRecord = + MutationExecutionRecord( + clientId = CLIENT_ID, + clientSequence = sequence, + phase = phase, + currentGeneration = generation, + attempt = attemptCount, + lastAttemptAt = lastAttemptAt, + activeFailureId = activeFailureId, + retiredAt = retiredAt, + ) + + private fun attempt( + sequence: Long, + generation: Int, + effectiveNamespace: String = "items", + effectiveCanonicalId: String = "item-$sequence", + valueCodecVersion: Int = 1, + base: ByteArray = byteArrayOf(4), + mine: ByteArray = byteArrayOf(6), + preconditionMetaPresent: Boolean = false, + preconditionWrittenAt: Long? = null, + preconditionEtag: String? = null, + advertisedRetiredThroughSequence: Long = 0L, + generationIdempotencyKey: String = "generation-$sequence-$generation", + conflictMetaPresent: Boolean? = null, + conflictWrittenAt: Long? = null, + conflictEtag: String? = null, + conflictReceivedAt: Long? = null, + ): MutationAttemptRecord = + MutationAttemptRecord( + clientId = CLIENT_ID, + clientSequence = sequence, + generation = generation, + effectiveNamespace = effectiveNamespace, + effectiveCanonicalId = effectiveCanonicalId, + valueCodecVersion = valueCodecVersion, + basePresence = MutationPresenceState.PRESENT, + baseBlob = base, + minePresence = MutationPresenceState.PRESENT, + mineBlob = mine, + preconditionMetaPresent = preconditionMetaPresent, + preconditionWrittenAt = preconditionWrittenAt, + preconditionEtag = preconditionEtag, + advertisedRetiredThroughSequence = advertisedRetiredThroughSequence, + generationIdempotencyKey = generationIdempotencyKey, + preparedAt = 20L + generation, + conflictMetaPresent = conflictMetaPresent, + conflictWrittenAt = conflictWrittenAt, + conflictEtag = conflictEtag, + conflictReceivedAt = conflictReceivedAt, + ) + + private fun ack( + sequence: Long, + generation: Int, + authoritative: ByteArray = byteArrayOf(7), + valueCodecVersion: Int = 1, + receivedAt: Long = 50L, + canonicalTargetNamespace: String = "items", + canonicalTargetId: String = "canonical-$sequence", + ): MutationAckRecord = + MutationAckRecord( + clientId = CLIENT_ID, + clientSequence = sequence, + generation = generation, + authoritativePresence = MutationPresenceState.PRESENT, + authoritativeBlob = authoritative, + valueCodecVersion = valueCodecVersion, + etag = "etag", + canonicalTargetNamespace = canonicalTargetNamespace, + canonicalTargetId = canonicalTargetId, + receivedAt = receivedAt, + ) + + private fun absentAck( + sequence: Long, + generation: Int, + ): MutationAckRecord = + MutationAckRecord( + clientId = CLIENT_ID, + clientSequence = sequence, + generation = generation, + authoritativePresence = MutationPresenceState.ABSENT, + authoritativeBlob = null, + valueCodecVersion = 1, + etag = "delete-etag", + canonicalTargetNamespace = null, + canonicalTargetId = null, + receivedAt = 50L, + ) + + private fun sameIdentityAck( + sequence: Long, + generation: Int, + ): MutationAckRecord = + MutationAckRecord( + clientId = CLIENT_ID, + clientSequence = sequence, + generation = generation, + authoritativePresence = MutationPresenceState.PRESENT, + authoritativeBlob = byteArrayOf(7), + valueCodecVersion = 1, + etag = "etag", + canonicalTargetNamespace = null, + canonicalTargetId = null, + receivedAt = 40L, + ) + + private fun effect( + sequence: Long, + index: Int, + kind: MutationEffectKind = MutationEffectKind.KEY, + disposition: MutationEffectDisposition = MutationEffectDisposition.PENDING, + completedAt: Long? = null, + ): MutationEffectRecord = + MutationEffectRecord( + clientId = CLIENT_ID, + clientSequence = sequence, + effectIndex = index, + kind = kind, + namespace = "effects", + canonicalId = if (kind == MutationEffectKind.KEY) "target-$index" else null, + createdAt = 20L, + disposition = disposition, + completedAt = completedAt, + ) + + private fun alias( + sourceId: String = "item-1", + targetId: String = "canonical-1", + sequence: Long = 1L, + state: MutationAliasState = MutationAliasState.PENDING, + activatedAt: Long? = null, + ): MutationKeyAliasRecord = + MutationKeyAliasRecord( + sourceNamespace = "items", + sourceCanonicalId = sourceId, + targetNamespace = "items", + targetCanonicalId = targetId, + state = state, + createdByClientId = CLIENT_ID, + createdBySequence = sequence, + createdAt = 10L, + activatedAt = activatedAt, + ) + + private fun tombstone( + sequence: Long = 1L, + canonicalId: String = "item-1", + createdByClientId: String = CLIENT_ID, + state: MutationTombstoneState = MutationTombstoneState.PENDING, + createdAt: Long = 10L, + activatedAt: Long? = null, + supersededBySequence: Long? = null, + supersededByClientId: String? = supersededBySequence?.let { CLIENT_ID }, + supersededAt: Long? = null, + ): MutationKeyTombstoneRecord = + MutationKeyTombstoneRecord( + namespace = "items", + canonicalId = canonicalId, + createdByClientId = createdByClientId, + createdBySequence = sequence, + state = state, + createdAt = createdAt, + activatedAt = activatedAt, + supersededByClientId = supersededByClientId, + supersededBySequence = supersededBySequence, + supersededAt = supersededAt, + ) +} + +private const val CLIENT_ID: String = "client" +private const val OLDER_CLIENT_ID: String = "older-client" +private const val CAUSAL_SOURCE_ID: String = "causal-source" +private const val CAUSAL_MIDDLE_ID: String = "causal-middle" +private const val CAUSAL_TARGET_ID: String = "causal-target" +private const val CAUSAL_UNRELATED_ID: String = "causal-unrelated" + +private enum class C8OwnerState( + val phase: MutationExecutionPhase, +) { + INFLIGHT(MutationExecutionPhase.INFLIGHT), + REFRESH_REQUIRED(MutationExecutionPhase.REFRESH_REQUIRED), + ACKED(MutationExecutionPhase.ACKED), + EFFECTS_PENDING(MutationExecutionPhase.EFFECTS_PENDING), + RETRY_READY(MutationExecutionPhase.READY), + NEXT_GENERATION_READY(MutationExecutionPhase.READY), +} + +private class Rollback : RuntimeException() + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = 25.seconds, testBody = testBody) diff --git a/store6-mutations-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/testing/MutatorPurityContractKit.kt b/store6-mutations-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/testing/MutatorPurityContractKit.kt new file mode 100644 index 000000000..c5a043fa3 --- /dev/null +++ b/store6-mutations-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/testing/MutatorPurityContractKit.kt @@ -0,0 +1,251 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.mutations.testing + +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.mutations.MutationCodec +import org.mobilenativefoundation.store6.mutations.MutationPresence +import org.mobilenativefoundation.store6.mutations.MutatorRef +import org.mobilenativefoundation.store6.mutations.MutatorRegistry +import org.mobilenativefoundation.store6.mutations.MutatorRegistryBuilder +import org.mobilenativefoundation.store6.mutations.StaleSet +import org.mobilenativefoundation.store6.mutations.mutatorRegistry +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * One representative input pair for [MutatorPurityContractKit]. + * + * The factories must return fresh, structurally equivalent values on every call. Fresh values + * keep one projection invocation from mutating the inputs observed by a later invocation. + */ +@ExperimentalStoreApi +public class MutatorPuritySample( + /** Human-readable case name included in assertion failures. */ + public val name: String, + internal val newBase: () -> MutationPresence, + internal val newArgs: () -> A, +) { + init { + require(name.isNotBlank()) { "A mutator-purity sample name must not be blank." } + } +} + +/** + * One bounded ambient-state transition exercised by [MutatorPurityContractKit]. + * + * Arbitrary ambient state cannot be discovered by a black-box kit. Consumers therefore name the + * state relevant to their projector and provide baseline/changed transitions plus restoration. + */ +@ExperimentalStoreApi +public class MutatorAmbientProbe( + /** Human-readable probe name included in assertion failures. */ + public val name: String, + internal val enterBaseline: () -> Unit, + internal val enterChanged: () -> Unit, + internal val restore: () -> Unit, +) { + init { + require(name.isNotBlank()) { "A mutator ambient-probe name must not be blank." } + } +} + +/** + * A registry-bound projector subject consumed by [MutatorPurityContractKit]. + * + * Construct subjects with [mutatorPuritySubject]. That factory installs and retains the same + * projector lambda, so the tested function cannot drift from the function registered under [ref]. + */ +@ExperimentalStoreApi +public class MutatorPuritySubject internal constructor( + /** Registry containing the projector under test. */ + public val registry: MutatorRegistry, + /** Typed reference returned by the exact registration under test. */ + public val ref: MutatorRef, + internal val samples: List>, + internal val ambientProbes: List, + internal val snapshotValue: (V) -> S, + internal val snapshotArgs: (A) -> List, + internal val project: (MutationPresence, A) -> MutationPresence?, +) + +/** + * Registers a projector and returns the exact registry-bound subject used by the purity kit. + * + * [snapshotValue] must return detached structural data suitable for equality checks. This is + * required because `MutationPresence.Present` deliberately does not define structural equality. + * [ambientProbes] bounds the external state the consumer claims the projector ignores. + */ +@ExperimentalStoreApi +public fun mutatorPuritySubject( + id: String, + version: Int, + codec: MutationCodec, + stales: (K, A) -> StaleSet, + samples: List>, + snapshotValue: (V) -> S, + ambientProbes: List, + configure: MutatorRegistryBuilder.() -> Unit = {}, + project: (MutationPresence, A) -> MutationPresence?, +): MutatorPuritySubject { + require(samples.isNotEmpty()) { "Mutator purity requires at least one base/args sample." } + require(ambientProbes.isNotEmpty()) { "Mutator purity requires at least one ambient-state probe." } + require(samples.map { sample -> sample.name }.distinct().size == samples.size) { + "Mutator-purity sample names must be unique." + } + require(ambientProbes.map { probe -> probe.name }.distinct().size == ambientProbes.size) { + "Mutator ambient-probe names must be unique." + } + + lateinit var ref: MutatorRef + val registry = + mutatorRegistry { + configure() + ref = + mutator( + id = id, + version = version, + codec = codec, + stales = stales, + project = project, + ) + } + return MutatorPuritySubject( + registry = registry, + ref = ref, + samples = samples.toList(), + ambientProbes = ambientProbes.toList(), + snapshotValue = snapshotValue, + snapshotArgs = { args -> codec.encode(args).toList() }, + project = project, + ) +} + +/** + * Published conformance kit for durable mutator projectors. + * + * Extend this class in a consumer test source set and return a subject built by + * [mutatorPuritySubject]. The inherited tests prove, over the supplied representative samples and + * ambient probes, that local application is a pure deterministic function of `(base, args)`. + * Double application compares two independent two-step replay traces; it does not require + * idempotence, which would incorrectly reject lawful append and increment mutators. + */ +@ExperimentalStoreApi +public abstract class MutatorPurityContractKit { + /** Creates a fresh registry-bound subject for one inherited contract test. */ + public abstract fun createSubject(): MutatorPuritySubject + + /** Same fresh input pair always produces structurally equal output. */ + @Test + public fun repeatDeterminism_sameInputsProduceStructurallyEqualOutput() { + val subject = createSubject() + subject.samples.forEach { sample -> + val expected = subject.projectSnapshot(sample) + val actual = subject.projectSnapshot(sample) + assertEquals(expected, actual, "repeat determinism: ${sample.name}") + } + } + + /** Two independent `project(project(base, args), args)` replay traces are equivalent. */ + @Test + public fun doubleApplication_independentReplayTracesAreEquivalent() { + val subject = createSubject() + subject.samples.forEach { sample -> + val expected = subject.doubleApplicationTrace(sample) + val actual = subject.doubleApplicationTrace(sample) + assertEquals(expected, actual, "double-application replay: ${sample.name}") + } + } + + /** Calls made before a projection do not change its output for the same fresh input pair. */ + @Test + public fun invocationCount_doesNotAffectProjection() { + val subject = createSubject() + subject.samples.forEach { sample -> + val expected = subject.projectSnapshot(sample) + repeat(3) { + subject.samples.forEach(subject::projectSnapshot) + } + val actual = subject.projectSnapshot(sample) + assertEquals(expected, actual, "invocation-count independence: ${sample.name}") + } + } + + /** Named ambient-state changes do not change output for the same fresh input pair. */ + @Test + public fun ambientState_doesNotAffectProjection() { + val subject = createSubject() + subject.ambientProbes.forEach { probe -> + subject.samples.forEach { sample -> + try { + probe.enterBaseline() + val expected = subject.projectSnapshot(sample) + probe.enterChanged() + val actual = subject.projectSnapshot(sample) + assertEquals( + expected, + actual, + "ambient-state independence: ${probe.name}/${sample.name}", + ) + } finally { + probe.restore() + } + } + } + } +} + +private sealed interface ProjectionSnapshot { + data object Declined : ProjectionSnapshot + + data object Absent : ProjectionSnapshot + + data class Present(val value: S) : ProjectionSnapshot +} + +private data class DoubleApplicationTrace( + val first: ProjectionSnapshot, + val second: ProjectionSnapshot, +) + +private fun + MutatorPuritySubject.projectSnapshot( + sample: MutatorPuritySample, + ): ProjectionSnapshot = normalize(checkedProject(sample.newBase(), sample.newArgs())) + +private fun + MutatorPuritySubject.doubleApplicationTrace( + sample: MutatorPuritySample, + ): DoubleApplicationTrace { + val base = sample.newBase() + val firstRaw = checkedProject(base, sample.newArgs()) + val secondRaw = checkedProject(firstRaw ?: base, sample.newArgs()) + return DoubleApplicationTrace( + first = normalize(firstRaw), + second = normalize(secondRaw), + ) +} + +private fun + MutatorPuritySubject.checkedProject( + base: MutationPresence, + args: A, + ): MutationPresence? { + val baseBefore = normalize(base) + val argsBefore = snapshotArgs(args) + val result = project(base, args) + assertEquals(baseBefore, normalize(base), "projector mutated its base input") + assertEquals(argsBefore, snapshotArgs(args), "projector mutated its args input") + return result +} + +private fun + MutatorPuritySubject.normalize( + presence: MutationPresence?, + ): ProjectionSnapshot = + when (presence) { + null -> ProjectionSnapshot.Declined + MutationPresence.Absent -> ProjectionSnapshot.Absent + is MutationPresence.Present -> ProjectionSnapshot.Present(snapshotValue(presence.value)) + } diff --git a/store6-mutations-testing/src/commonTest/kotlin/MutatorPurityDocsSnippet.kt b/store6-mutations-testing/src/commonTest/kotlin/MutatorPurityDocsSnippet.kt new file mode 100644 index 000000000..8447a4732 --- /dev/null +++ b/store6-mutations-testing/src/commonTest/kotlin/MutatorPurityDocsSnippet.kt @@ -0,0 +1,80 @@ +// docs:snippet:mutations-testing-projector-purity +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.mutations.MutationPresence +import org.mobilenativefoundation.store6.mutations.StaleSet +import org.mobilenativefoundation.store6.mutations.testing.MutatorAmbientProbe +import org.mobilenativefoundation.store6.mutations.testing.MutatorPurityContractKit +import org.mobilenativefoundation.store6.mutations.testing.MutatorPuritySample +import org.mobilenativefoundation.store6.mutations.testing.mutatorPuritySubject + +@OptIn(ExperimentalStoreApi::class) +class RenameProjectorContractTest : + MutatorPurityContractKit>() { + private var ambientLocaleTag: String = "en-US" + + override fun createSubject() = + mutatorPuritySubject>( + id = "rename", + version = 1, + codec = RenameCodec, + stales = { _, _ -> StaleSet(keys = emptySet(), namespaces = emptySet()) }, + samples = + listOf( + MutatorPuritySample( + name = "rename a present user", + newBase = { MutationPresence.Present(User(id = "42", name = "Ada")) }, + newArgs = { Rename(name = "Grace") }, + ), + ), + snapshotValue = { user -> user.id to user.name }, + ambientProbes = + listOf( + MutatorAmbientProbe( + name = "locale tag", + enterBaseline = { ambientLocaleTag = "en-US" }, + enterChanged = { ambientLocaleTag = "tr-TR" }, + restore = { ambientLocaleTag = "en-US" }, + ), + ), + project = { base, rename -> + when (base) { + is MutationPresence.Present -> + MutationPresence.Present(base.value.copy(name = rename.name)) + MutationPresence.Absent -> null + } + }, + ) +} +// docs:snippet:end + +class UserKey( + private val id: String, +) : org.mobilenativefoundation.store6.core.StoreKey { + override val namespace: org.mobilenativefoundation.store6.core.StoreNamespace = + org.mobilenativefoundation.store6.core.StoreNamespace("users") + + override fun canonicalId(): String = id +} + +data class User( + val id: String, + val name: String, +) + +data class Rename( + val name: String, +) + +@OptIn(ExperimentalStoreApi::class) +private object RenameCodec : + org.mobilenativefoundation.store6.mutations.MutationCodec { + override fun encode(value: Rename): ByteArray = value.name.encodeToByteArray() + + override fun decode( + version: Int, + bytes: ByteArray, + ): Rename { + require(version == 1) + return Rename(bytes.decodeToString()) + } +} diff --git a/store6-mutations-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/testing/InMemoryJournalStorageContractTest.kt b/store6-mutations-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/testing/InMemoryJournalStorageContractTest.kt new file mode 100644 index 000000000..9c1c11b53 --- /dev/null +++ b/store6-mutations-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/testing/InMemoryJournalStorageContractTest.kt @@ -0,0 +1,12 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.mutations.testing + +import org.mobilenativefoundation.store6.mutations.storage.InMemoryMutationJournalStorage +import org.mobilenativefoundation.store6.mutations.storage.MutationJournalStorage + +class InMemoryJournalStorageContractTest : MutationJournalStorageContractKit() { + override fun createStorage(): MutationJournalStorage = InMemoryMutationJournalStorage() + + override fun reopenStorage(previous: MutationJournalStorage): MutationJournalStorage = previous +} diff --git a/store6-mutations-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/testing/MutatorPurityKitSelfTest.kt b/store6-mutations-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/testing/MutatorPurityKitSelfTest.kt new file mode 100644 index 000000000..8b8807e34 --- /dev/null +++ b/store6-mutations-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/testing/MutatorPurityKitSelfTest.kt @@ -0,0 +1,214 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.mutations.testing + +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.mutations.MutationCodec +import org.mobilenativefoundation.store6.mutations.MutationPresence +import org.mobilenativefoundation.store6.mutations.StaleSet +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertFailsWith + +class MutatorPurityKitSelfTest : + MutatorPurityContractKit() { + private var ambientValue: Int = 0 + + override fun createSubject(): MutatorPuritySubject = + puritySubject( + ambientProbes = listOf(ambientProbe { value -> ambientValue = value }), + ) { base, args -> + val current = (base as? MutationPresence.Present)?.value.orEmpty() + when (args) { + "decline" -> null + "delete" -> MutationPresence.Absent + else -> MutationPresence.Present(current + args) + } + } + + @Test + fun counterCapturedProjector_isRejected() { + var invocationCount = 0 + var ambient = 0 + val impure = + object : MutatorPurityContractKit() { + override fun createSubject(): MutatorPuritySubject = + puritySubject( + ambientProbes = listOf(ambientProbe { value -> ambient = value }), + ) { base, args -> + invocationCount += 1 + val current = (base as? MutationPresence.Present)?.value.orEmpty() + MutationPresence.Present("$current$args#$invocationCount") + } + } + + val failure = + assertFailsWith { + impure.repeatDeterminism_sameInputsProduceStructurallyEqualOutput() + } + assertContains(failure.message.orEmpty(), "repeat determinism") + } + + @Test + fun ambientCapturedProjector_isRejected() { + var ambient = 0 + val impure = + object : MutatorPurityContractKit() { + override fun createSubject(): MutatorPuritySubject = + puritySubject( + ambientProbes = listOf(ambientProbe { value -> ambient = value }), + ) { base, args -> + val current = (base as? MutationPresence.Present)?.value.orEmpty() + MutationPresence.Present("$current$args@$ambient") + } + } + + val failure = + assertFailsWith { + impure.ambientState_doesNotAffectProjection() + } + assertContains(failure.message.orEmpty(), "ambient-state independence") + } + + @Test + fun baseMutatingProjector_isRejected() { + var ambient = 0 + val impure = + object : MutatorPurityContractKit() { + override fun createSubject(): + MutatorPuritySubject = + mutatorPuritySubject( + id = "base-mutator", + version = 1, + codec = PurityStringCodec, + stales = { _, _ -> StaleSet(emptySet(), emptySet()) }, + samples = + listOf( + MutatorPuritySample( + name = "mutable base", + newBase = { MutationPresence.Present(StringBuilder("base")) }, + newArgs = { "+suffix" }, + ), + ), + snapshotValue = { value -> value.toString() }, + ambientProbes = listOf(ambientProbe { value -> ambient = value }), + ) { base, args -> + val mutable = (base as MutationPresence.Present).value + mutable.append(args) + MutationPresence.Present(mutable) + } + } + + assertFailsWith { + impure.repeatDeterminism_sameInputsProduceStructurallyEqualOutput() + } + } + + @Test + fun argsMutatingProjector_isRejected() { + var ambient = 0 + val impure = + object : MutatorPurityContractKit() { + override fun createSubject(): + MutatorPuritySubject = + mutatorPuritySubject( + id = "args-mutator", + version = 1, + codec = PurityStringBuilderCodec, + stales = { _, _ -> StaleSet(emptySet(), emptySet()) }, + samples = + listOf( + MutatorPuritySample( + name = "mutable args", + newBase = { MutationPresence.Present("base") }, + newArgs = { StringBuilder("+suffix") }, + ), + ), + snapshotValue = { value -> value }, + ambientProbes = listOf(ambientProbe { value -> ambient = value }), + ) { base, args -> + args.append("!") + val current = (base as MutationPresence.Present).value + MutationPresence.Present(current + args) + } + } + + assertFailsWith { + impure.repeatDeterminism_sameInputsProduceStructurallyEqualOutput() + } + } +} + +private fun puritySubject( + ambientProbes: List, + project: (MutationPresence, String) -> MutationPresence?, +): MutatorPuritySubject = + mutatorPuritySubject( + id = "append-decline-delete", + version = 1, + codec = PurityStringCodec, + stales = { _, _ -> StaleSet(emptySet(), emptySet()) }, + samples = puritySamples(), + snapshotValue = { value -> value }, + ambientProbes = ambientProbes, + project = project, + ) + +private fun ambientProbe(setValue: (Int) -> Unit): MutatorAmbientProbe = + MutatorAmbientProbe( + name = "ambient integer", + enterBaseline = { setValue(0) }, + enterChanged = { setValue(41) }, + restore = { setValue(0) }, + ) + +class PurityKey( + private val id: String, +) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("purity") + + override fun canonicalId(): String = id +} + +private object PurityStringCodec : MutationCodec { + override fun encode(value: String): ByteArray = value.encodeToByteArray() + + override fun decode( + version: Int, + bytes: ByteArray, + ): String = bytes.decodeToString() +} + +private object PurityStringBuilderCodec : MutationCodec { + override fun encode(value: StringBuilder): ByteArray = value.toString().encodeToByteArray() + + override fun decode( + version: Int, + bytes: ByteArray, + ): StringBuilder = StringBuilder(bytes.decodeToString()) +} + +private fun puritySamples(): List> = + listOf( + MutatorPuritySample( + name = "absent plus suffix", + newBase = { MutationPresence.Absent }, + newArgs = { "+one" }, + ), + MutatorPuritySample( + name = "present plus suffix", + newBase = { MutationPresence.Present("base") }, + newArgs = { "+two" }, + ), + MutatorPuritySample( + name = "declined update", + newBase = { MutationPresence.Present("base") }, + newArgs = { "decline" }, + ), + MutatorPuritySample( + name = "delete", + newBase = { MutationPresence.Present("base") }, + newArgs = { "delete" }, + ), + ) diff --git a/store6-mutations/api/android/store6-mutations.api b/store6-mutations/api/android/store6-mutations.api new file mode 100644 index 000000000..bfb6f3cb3 --- /dev/null +++ b/store6-mutations/api/android/store6-mutations.api @@ -0,0 +1,549 @@ +public final class org/mobilenativefoundation/store6/mutations/DeadLetter { + public final fun getAttempts ()I + public final fun getCanonicalId ()Ljava/lang/String; + public final fun getFailure ()Lorg/mobilenativefoundation/store6/mutations/MutationFailure; + public final fun getGeneration ()I + public final fun getMutationId ()Ljava/lang/String; + public final fun getMutatorId ()Ljava/lang/String; + public final fun getNamespace ()Ljava/lang/String; + public final fun getParkedAtEpochMillis ()J +} + +public final class org/mobilenativefoundation/store6/mutations/MutationAbsentAck : org/mobilenativefoundation/store6/mutations/MutationAck { + public fun (Ljava/lang/String;)V + public fun getEtag ()Ljava/lang/String; +} + +public abstract interface class org/mobilenativefoundation/store6/mutations/MutationAck { + public abstract fun getEtag ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/mutations/MutationAcknowledged : org/mobilenativefoundation/store6/mutations/MutationIntentEvent { + public final fun getGeneration ()I + public fun getIdentity ()Lorg/mobilenativefoundation/store6/mutations/MutationKeyIdentity; + public fun getMutationId ()Ljava/lang/String; + public fun getOccurredAtEpochMillis ()J + public final fun getPresence ()Lorg/mobilenativefoundation/store6/mutations/MutationPresenceState; +} + +public final class org/mobilenativefoundation/store6/mutations/MutationAdopted : org/mobilenativefoundation/store6/mutations/MutationIntentEvent { + public final fun getGeneration ()I + public fun getIdentity ()Lorg/mobilenativefoundation/store6/mutations/MutationKeyIdentity; + public fun getMutationId ()Ljava/lang/String; + public fun getOccurredAtEpochMillis ()J + public final fun getPresence ()Lorg/mobilenativefoundation/store6/mutations/MutationPresenceState; +} + +public final class org/mobilenativefoundation/store6/mutations/MutationAttempted : org/mobilenativefoundation/store6/mutations/MutationIntentEvent { + public final fun getAttempt ()I + public final fun getGeneration ()I + public fun getIdentity ()Lorg/mobilenativefoundation/store6/mutations/MutationKeyIdentity; + public fun getMutationId ()Ljava/lang/String; + public fun getOccurredAtEpochMillis ()J +} + +public final class org/mobilenativefoundation/store6/mutations/MutationCheckpointConfirmed : org/mobilenativefoundation/store6/mutations/MutationEvent { + public final fun getClientId ()Ljava/lang/String; + public final fun getConfirmedThroughSequence ()J + public fun getOccurredAtEpochMillis ()J + public final fun getRequestedThroughSequence ()J +} + +public final class org/mobilenativefoundation/store6/mutations/MutationCheckpointFailed : org/mobilenativefoundation/store6/mutations/MutationEvent { + public final fun getClientId ()Ljava/lang/String; + public final fun getFailure ()Lorg/mobilenativefoundation/store6/mutations/MutationFailure; + public fun getOccurredAtEpochMillis ()J + public final fun getRequestedThroughSequence ()J +} + +public abstract interface class org/mobilenativefoundation/store6/mutations/MutationCodec { + public abstract fun decode (I[B)Ljava/lang/Object; + public abstract fun encode (Ljava/lang/Object;)[B +} + +public final class org/mobilenativefoundation/store6/mutations/MutationConflictBuilder { + public final fun merge (Lkotlin/jvm/functions/Function3;)V + public final fun precondition (Lkotlin/jvm/functions/Function1;)V +} + +public final class org/mobilenativefoundation/store6/mutations/MutationConflictObserved : org/mobilenativefoundation/store6/mutations/MutationIntentEvent { + public final fun getGeneration ()I + public fun getIdentity ()Lorg/mobilenativefoundation/store6/mutations/MutationKeyIdentity; + public fun getMutationId ()Ljava/lang/String; + public fun getOccurredAtEpochMillis ()J + public final fun getServerMeta ()Lorg/mobilenativefoundation/store6/core/StoreMeta; +} + +public abstract interface class org/mobilenativefoundation/store6/mutations/MutationConflictResolution { +} + +public final class org/mobilenativefoundation/store6/mutations/MutationConflictResolution$Retry : org/mobilenativefoundation/store6/mutations/MutationConflictResolution { + public fun (Lorg/mobilenativefoundation/store6/mutations/MutationPresence;)V + public final fun getValue ()Lorg/mobilenativefoundation/store6/mutations/MutationPresence; +} + +public final class org/mobilenativefoundation/store6/mutations/MutationConflictResolution$ServerWins : org/mobilenativefoundation/store6/mutations/MutationConflictResolution { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/mutations/MutationConflictResolution$ServerWins; +} + +public final class org/mobilenativefoundation/store6/mutations/MutationEffectApplied : org/mobilenativefoundation/store6/mutations/MutationIntentEvent { + public final fun getEffectIndex ()I + public final fun getGeneration ()I + public fun getIdentity ()Lorg/mobilenativefoundation/store6/mutations/MutationKeyIdentity; + public fun getMutationId ()Ljava/lang/String; + public fun getOccurredAtEpochMillis ()J +} + +public final class org/mobilenativefoundation/store6/mutations/MutationEffectSkipped : org/mobilenativefoundation/store6/mutations/MutationIntentEvent { + public final fun getEffectIndex ()I + public final fun getGeneration ()I + public fun getIdentity ()Lorg/mobilenativefoundation/store6/mutations/MutationKeyIdentity; + public fun getMutationId ()Ljava/lang/String; + public fun getOccurredAtEpochMillis ()J +} + +public final class org/mobilenativefoundation/store6/mutations/MutationEnqueued : org/mobilenativefoundation/store6/mutations/MutationIntentEvent { + public final fun getClientSequence ()J + public fun getIdentity ()Lorg/mobilenativefoundation/store6/mutations/MutationKeyIdentity; + public fun getMutationId ()Ljava/lang/String; + public final fun getMutatorId ()Ljava/lang/String; + public fun getOccurredAtEpochMillis ()J +} + +public abstract interface class org/mobilenativefoundation/store6/mutations/MutationEvent { + public abstract fun getOccurredAtEpochMillis ()J +} + +public final class org/mobilenativefoundation/store6/mutations/MutationFailed : org/mobilenativefoundation/store6/mutations/MutationIntentEvent { + public final fun getFailure ()Lorg/mobilenativefoundation/store6/mutations/MutationFailure; + public final fun getGeneration ()I + public fun getIdentity ()Lorg/mobilenativefoundation/store6/mutations/MutationKeyIdentity; + public fun getMutationId ()Ljava/lang/String; + public fun getOccurredAtEpochMillis ()J + public final fun getState ()Lorg/mobilenativefoundation/store6/mutations/MutationPendingState; +} + +public final class org/mobilenativefoundation/store6/mutations/MutationFailure { + public final fun getDetail ()Ljava/lang/String; + public final fun getKind ()Lorg/mobilenativefoundation/store6/mutations/MutationFailureKind; + public final fun getMessage ()Ljava/lang/String; + public final fun getOccurredAtEpochMillis ()J +} + +public final class org/mobilenativefoundation/store6/mutations/MutationFailureKind : java/lang/Enum { + public static final field ADOPTION Lorg/mobilenativefoundation/store6/mutations/MutationFailureKind; + public static final field CODEC Lorg/mobilenativefoundation/store6/mutations/MutationFailureKind; + public static final field CONFLICT Lorg/mobilenativefoundation/store6/mutations/MutationFailureKind; + public static final field EFFECT Lorg/mobilenativefoundation/store6/mutations/MutationFailureKind; + public static final field IDENTITY Lorg/mobilenativefoundation/store6/mutations/MutationFailureKind; + public static final field PERSISTENCE Lorg/mobilenativefoundation/store6/mutations/MutationFailureKind; + public static final field PROJECTION Lorg/mobilenativefoundation/store6/mutations/MutationFailureKind; + public static final field PROTOCOL Lorg/mobilenativefoundation/store6/mutations/MutationFailureKind; + public static final field TRANSPORT Lorg/mobilenativefoundation/store6/mutations/MutationFailureKind; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/mobilenativefoundation/store6/mutations/MutationFailureKind; + public static fun values ()[Lorg/mobilenativefoundation/store6/mutations/MutationFailureKind; +} + +public abstract interface class org/mobilenativefoundation/store6/mutations/MutationIntentEvent : org/mobilenativefoundation/store6/mutations/MutationEvent { + public abstract fun getIdentity ()Lorg/mobilenativefoundation/store6/mutations/MutationKeyIdentity; + public abstract fun getMutationId ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/mutations/MutationKeyIdentity { + public final fun getCanonicalId ()Ljava/lang/String; + public final fun getNamespace ()Ljava/lang/String; +} + +public abstract interface class org/mobilenativefoundation/store6/mutations/MutationKeyResolver { + public abstract fun resolve (Lorg/mobilenativefoundation/store6/mutations/MutationKeyIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class org/mobilenativefoundation/store6/mutations/MutationParked : org/mobilenativefoundation/store6/mutations/MutationIntentEvent { + public final fun getFailure ()Lorg/mobilenativefoundation/store6/mutations/MutationFailure; + public final fun getGeneration ()I + public fun getIdentity ()Lorg/mobilenativefoundation/store6/mutations/MutationKeyIdentity; + public fun getMutationId ()Ljava/lang/String; + public fun getOccurredAtEpochMillis ()J +} + +public final class org/mobilenativefoundation/store6/mutations/MutationPendingState : java/lang/Enum { + public static final field ADOPTING Lorg/mobilenativefoundation/store6/mutations/MutationPendingState; + public static final field APPLYING_EFFECTS Lorg/mobilenativefoundation/store6/mutations/MutationPendingState; + public static final field INFLIGHT Lorg/mobilenativefoundation/store6/mutations/MutationPendingState; + public static final field PENDING Lorg/mobilenativefoundation/store6/mutations/MutationPendingState; + public static final field REFRESHING Lorg/mobilenativefoundation/store6/mutations/MutationPendingState; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/mobilenativefoundation/store6/mutations/MutationPendingState; + public static fun values ()[Lorg/mobilenativefoundation/store6/mutations/MutationPendingState; +} + +public final class org/mobilenativefoundation/store6/mutations/MutationPreconditionCandidate { + public final fun getBase ()Lorg/mobilenativefoundation/store6/mutations/MutationPresence; + public final fun getCapturedMeta ()Lorg/mobilenativefoundation/store6/core/StoreMeta; + public final fun getGeneration ()I + public final fun getIdentity ()Lorg/mobilenativefoundation/store6/mutations/MutationKeyIdentity; + public final fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; + public final fun getMine ()Lorg/mobilenativefoundation/store6/mutations/MutationPresence; + public final fun getMutationId ()Ljava/lang/String; +} + +public abstract interface class org/mobilenativefoundation/store6/mutations/MutationPresence { +} + +public final class org/mobilenativefoundation/store6/mutations/MutationPresence$Absent : org/mobilenativefoundation/store6/mutations/MutationPresence { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/mutations/MutationPresence$Absent; +} + +public final class org/mobilenativefoundation/store6/mutations/MutationPresence$Present : org/mobilenativefoundation/store6/mutations/MutationPresence { + public fun (Ljava/lang/Object;)V + public final fun getValue ()Ljava/lang/Object; +} + +public final class org/mobilenativefoundation/store6/mutations/MutationPresenceState : java/lang/Enum { + public static final field ABSENT Lorg/mobilenativefoundation/store6/mutations/MutationPresenceState; + public static final field PRESENT Lorg/mobilenativefoundation/store6/mutations/MutationPresenceState; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/mobilenativefoundation/store6/mutations/MutationPresenceState; + public static fun values ()[Lorg/mobilenativefoundation/store6/mutations/MutationPresenceState; +} + +public final class org/mobilenativefoundation/store6/mutations/MutationPresentAck : org/mobilenativefoundation/store6/mutations/MutationAck { + public fun (Ljava/lang/Object;Ljava/lang/String;Lorg/mobilenativefoundation/store6/core/StoreKey;)V + public final fun getAuthoritative ()Ljava/lang/Object; + public final fun getCanonicalKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; + public fun getEtag ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/mutations/MutationPush { + public final fun getBase ()Lorg/mobilenativefoundation/store6/mutations/MutationPresence; + public final fun getBaseMeta ()Lorg/mobilenativefoundation/store6/core/StoreMeta; + public final fun getClientId ()Ljava/lang/String; + public final fun getClientSequence ()J + public final fun getGeneration ()I + public final fun getIdempotencyKey ()Ljava/lang/String; + public final fun getIdentity ()Lorg/mobilenativefoundation/store6/mutations/MutationKeyIdentity; + public final fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; + public final fun getMine ()Lorg/mobilenativefoundation/store6/mutations/MutationPresence; + public final fun getMutationId ()Ljava/lang/String; + public final fun getRetiredThroughSequence ()J + public final fun getValueCodecVersion ()I +} + +public final class org/mobilenativefoundation/store6/mutations/MutationRetired : org/mobilenativefoundation/store6/mutations/MutationIntentEvent { + public final fun getGeneration ()I + public fun getIdentity ()Lorg/mobilenativefoundation/store6/mutations/MutationKeyIdentity; + public fun getMutationId ()Ljava/lang/String; + public fun getOccurredAtEpochMillis ()J + public final fun getRetiredThroughSequence ()J +} + +public final class org/mobilenativefoundation/store6/mutations/MutationRetirement { + public final fun getClientId ()Ljava/lang/String; + public final fun getRetiredThroughSequence ()J +} + +public final class org/mobilenativefoundation/store6/mutations/MutationRetirementAck { + public fun (J)V + public final fun getConfirmedThroughSequence ()J +} + +public abstract interface class org/mobilenativefoundation/store6/mutations/MutationServer { + public abstract fun push (Lorg/mobilenativefoundation/store6/mutations/MutationPush;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun retire (Lorg/mobilenativefoundation/store6/mutations/MutationRetirement;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class org/mobilenativefoundation/store6/mutations/MutationStore : org/mobilenativefoundation/store6/core/Store { + public fun clear (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun clearAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun clearNamespace (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun close ()V + public final fun deadLetters (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun drain (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun drain (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun get (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Freshness;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun getEvents ()Lkotlinx/coroutines/flow/SharedFlow; + public final fun getKeyEvents ()Lkotlinx/coroutines/flow/Flow; + public final fun getPoisoned ()Lkotlinx/coroutines/flow/SharedFlow; + public fun invalidate (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun invalidateAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun invalidateNamespace (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun mutate (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/mutations/MutatorRef;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun pending (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun pendingWrites (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun stream (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Freshness;)Lkotlinx/coroutines/flow/Flow; +} + +public final class org/mobilenativefoundation/store6/mutations/MutationStoreBuilder { + public final fun bookkeeper (Lorg/mobilenativefoundation/store6/core/seam/Bookkeeper;)V + public final fun conflicts (Lkotlin/jvm/functions/Function1;)V + public final fun fetcher (Lkotlin/jvm/functions/Function2;)V + public final fun fetcher (Lorg/mobilenativefoundation/store6/core/seam/Fetcher;)V + public final fun fetcherOfResult (Lkotlin/jvm/functions/Function2;)V + public final fun freshnessValidator (Lorg/mobilenativefoundation/store6/core/seam/FreshnessValidator;)V + public final fun journalStorage (Lorg/mobilenativefoundation/store6/mutations/storage/MutationJournalStorage;)V + public final fun maxIdleKeys (I)V + public final fun persistence (Lorg/mobilenativefoundation/store6/core/seam/SourceOfTruth;)V + public final fun telemetry (Lorg/mobilenativefoundation/store6/core/seam/StoreTelemetry;)V + public final fun wallClock (Lorg/mobilenativefoundation/store6/core/seam/WallClock;)V +} + +public final class org/mobilenativefoundation/store6/mutations/MutationStoreKt { + public static final fun mutationStore (Lorg/mobilenativefoundation/store6/mutations/MutatorRegistry;Lorg/mobilenativefoundation/store6/mutations/MutationServer;Lorg/mobilenativefoundation/store6/mutations/MutationKeyResolver;ILorg/mobilenativefoundation/store6/mutations/MutationCodec;Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store6/mutations/MutationStore; +} + +public final class org/mobilenativefoundation/store6/mutations/MutatorRef { + public final fun getId ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/mutations/MutatorRegistry { +} + +public final class org/mobilenativefoundation/store6/mutations/MutatorRegistryBuilder { + public final fun create (Ljava/lang/String;ILorg/mobilenativefoundation/store6/mutations/MutationCodec;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store6/mutations/MutatorRef; + public final fun delete (Ljava/lang/String;Lkotlin/jvm/functions/Function2;)Lorg/mobilenativefoundation/store6/mutations/MutatorRef; + public final fun mutator (Ljava/lang/String;ILorg/mobilenativefoundation/store6/mutations/MutationCodec;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;)Lorg/mobilenativefoundation/store6/mutations/MutatorRef; + public final fun update (Ljava/lang/String;ILorg/mobilenativefoundation/store6/mutations/MutationCodec;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;)Lorg/mobilenativefoundation/store6/mutations/MutatorRef; + public final fun upsert (Ljava/lang/String;ILorg/mobilenativefoundation/store6/mutations/MutationCodec;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;)Lorg/mobilenativefoundation/store6/mutations/MutatorRef; +} + +public final class org/mobilenativefoundation/store6/mutations/MutatorRegistryKt { + public static final fun mutatorRegistry (Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store6/mutations/MutatorRegistry; +} + +public final class org/mobilenativefoundation/store6/mutations/PendingIntent { + public final fun getAttempt ()I + public final fun getCanonicalId ()Ljava/lang/String; + public final fun getCreatedAtEpochMillis ()J + public final fun getMutationId ()Ljava/lang/String; + public final fun getMutatorId ()Ljava/lang/String; + public final fun getNamespace ()Ljava/lang/String; + public final fun getState ()Lorg/mobilenativefoundation/store6/mutations/MutationPendingState; +} + +public final class org/mobilenativefoundation/store6/mutations/PoisonedIntent { + public final fun getFailure ()Ljava/lang/Throwable; + public final fun getMutationId ()Ljava/lang/String; + public final fun getMutatorId ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/mutations/StaleSet { + public fun (Ljava/util/Set;Ljava/util/Set;)V + public final fun getKeys ()Ljava/util/Set; + public final fun getNamespaces ()Ljava/util/Set; +} + +public final class org/mobilenativefoundation/store6/mutations/storage/InMemoryMutationJournalStorage : org/mobilenativefoundation/store6/mutations/storage/MutationJournalStorage { + public fun ()V + public fun transaction (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class org/mobilenativefoundation/store6/mutations/storage/MutationAckRecord { + public fun (Ljava/lang/String;JILorg/mobilenativefoundation/store6/mutations/MutationPresenceState;[BILjava/lang/String;Ljava/lang/String;Ljava/lang/String;J)V + public final fun getAuthoritativeBlob ()[B + public final fun getAuthoritativePresence ()Lorg/mobilenativefoundation/store6/mutations/MutationPresenceState; + public final fun getCanonicalTargetId ()Ljava/lang/String; + public final fun getCanonicalTargetNamespace ()Ljava/lang/String; + public final fun getClientId ()Ljava/lang/String; + public final fun getClientSequence ()J + public final fun getEtag ()Ljava/lang/String; + public final fun getGeneration ()I + public final fun getReceivedAt ()J + public final fun getValueCodecVersion ()I +} + +public final class org/mobilenativefoundation/store6/mutations/storage/MutationAliasState : java/lang/Enum { + public static final field ACTIVE Lorg/mobilenativefoundation/store6/mutations/storage/MutationAliasState; + public static final field PENDING Lorg/mobilenativefoundation/store6/mutations/storage/MutationAliasState; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/mobilenativefoundation/store6/mutations/storage/MutationAliasState; + public static fun values ()[Lorg/mobilenativefoundation/store6/mutations/storage/MutationAliasState; +} + +public final class org/mobilenativefoundation/store6/mutations/storage/MutationAttemptRecord { + public fun (Ljava/lang/String;JILjava/lang/String;Ljava/lang/String;ILorg/mobilenativefoundation/store6/mutations/MutationPresenceState;[BLorg/mobilenativefoundation/store6/mutations/MutationPresenceState;[BZLjava/lang/Long;Ljava/lang/String;JLjava/lang/String;JLjava/lang/Boolean;Ljava/lang/Long;Ljava/lang/String;Ljava/lang/Long;)V + public final fun getAdvertisedRetiredThroughSequence ()J + public final fun getBaseBlob ()[B + public final fun getBasePresence ()Lorg/mobilenativefoundation/store6/mutations/MutationPresenceState; + public final fun getClientId ()Ljava/lang/String; + public final fun getClientSequence ()J + public final fun getConflictEtag ()Ljava/lang/String; + public final fun getConflictMetaPresent ()Ljava/lang/Boolean; + public final fun getConflictReceivedAt ()Ljava/lang/Long; + public final fun getConflictWrittenAt ()Ljava/lang/Long; + public final fun getEffectiveCanonicalId ()Ljava/lang/String; + public final fun getEffectiveNamespace ()Ljava/lang/String; + public final fun getGeneration ()I + public final fun getGenerationIdempotencyKey ()Ljava/lang/String; + public final fun getMineBlob ()[B + public final fun getMinePresence ()Lorg/mobilenativefoundation/store6/mutations/MutationPresenceState; + public final fun getPreconditionEtag ()Ljava/lang/String; + public final fun getPreconditionMetaPresent ()Z + public final fun getPreconditionWrittenAt ()Ljava/lang/Long; + public final fun getPreparedAt ()J + public final fun getValueCodecVersion ()I +} + +public final class org/mobilenativefoundation/store6/mutations/storage/MutationClientRecord { + public fun (ILjava/lang/String;JJJJ)V + public final fun getClientId ()Ljava/lang/String; + public final fun getCreatedAt ()J + public final fun getLastAllocatedSequence ()J + public final fun getRecordVersion ()I + public final fun getRetiredThroughSequence ()J + public final fun getServerConfirmedRetiredThroughSequence ()J +} + +public final class org/mobilenativefoundation/store6/mutations/storage/MutationEffectDisposition : java/lang/Enum { + public static final field APPLIED Lorg/mobilenativefoundation/store6/mutations/storage/MutationEffectDisposition; + public static final field PENDING Lorg/mobilenativefoundation/store6/mutations/storage/MutationEffectDisposition; + public static final field SKIPPED Lorg/mobilenativefoundation/store6/mutations/storage/MutationEffectDisposition; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/mobilenativefoundation/store6/mutations/storage/MutationEffectDisposition; + public static fun values ()[Lorg/mobilenativefoundation/store6/mutations/storage/MutationEffectDisposition; +} + +public final class org/mobilenativefoundation/store6/mutations/storage/MutationEffectKind : java/lang/Enum { + public static final field KEY Lorg/mobilenativefoundation/store6/mutations/storage/MutationEffectKind; + public static final field NAMESPACE Lorg/mobilenativefoundation/store6/mutations/storage/MutationEffectKind; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/mobilenativefoundation/store6/mutations/storage/MutationEffectKind; + public static fun values ()[Lorg/mobilenativefoundation/store6/mutations/storage/MutationEffectKind; +} + +public final class org/mobilenativefoundation/store6/mutations/storage/MutationEffectRecord { + public fun (Ljava/lang/String;JILorg/mobilenativefoundation/store6/mutations/storage/MutationEffectKind;Ljava/lang/String;Ljava/lang/String;JLorg/mobilenativefoundation/store6/mutations/storage/MutationEffectDisposition;Ljava/lang/Long;)V + public final fun getCanonicalId ()Ljava/lang/String; + public final fun getClientId ()Ljava/lang/String; + public final fun getClientSequence ()J + public final fun getCompletedAt ()Ljava/lang/Long; + public final fun getCreatedAt ()J + public final fun getDisposition ()Lorg/mobilenativefoundation/store6/mutations/storage/MutationEffectDisposition; + public final fun getEffectIndex ()I + public final fun getKind ()Lorg/mobilenativefoundation/store6/mutations/storage/MutationEffectKind; + public final fun getNamespace ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/mutations/storage/MutationExecutionPhase : java/lang/Enum { + public static final field ACKED Lorg/mobilenativefoundation/store6/mutations/storage/MutationExecutionPhase; + public static final field EFFECTS_PENDING Lorg/mobilenativefoundation/store6/mutations/storage/MutationExecutionPhase; + public static final field INFLIGHT Lorg/mobilenativefoundation/store6/mutations/storage/MutationExecutionPhase; + public static final field PARKED Lorg/mobilenativefoundation/store6/mutations/storage/MutationExecutionPhase; + public static final field READY Lorg/mobilenativefoundation/store6/mutations/storage/MutationExecutionPhase; + public static final field REFRESH_REQUIRED Lorg/mobilenativefoundation/store6/mutations/storage/MutationExecutionPhase; + public static final field RETIRED Lorg/mobilenativefoundation/store6/mutations/storage/MutationExecutionPhase; + public static final field UNPREPARED Lorg/mobilenativefoundation/store6/mutations/storage/MutationExecutionPhase; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/mobilenativefoundation/store6/mutations/storage/MutationExecutionPhase; + public static fun values ()[Lorg/mobilenativefoundation/store6/mutations/storage/MutationExecutionPhase; +} + +public final class org/mobilenativefoundation/store6/mutations/storage/MutationExecutionRecord { + public fun (Ljava/lang/String;JLorg/mobilenativefoundation/store6/mutations/storage/MutationExecutionPhase;IILjava/lang/Long;Ljava/lang/Long;Ljava/lang/Long;)V + public final fun getActiveFailureId ()Ljava/lang/Long; + public final fun getAttempt ()I + public final fun getClientId ()Ljava/lang/String; + public final fun getClientSequence ()J + public final fun getCurrentGeneration ()I + public final fun getLastAttemptAt ()Ljava/lang/Long; + public final fun getPhase ()Lorg/mobilenativefoundation/store6/mutations/storage/MutationExecutionPhase; + public final fun getRetiredAt ()Ljava/lang/Long; +} + +public final class org/mobilenativefoundation/store6/mutations/storage/MutationFailureRecord { + public fun (JLjava/lang/String;JILorg/mobilenativefoundation/store6/mutations/MutationFailureKind;Ljava/lang/String;Ljava/lang/String;J)V + public final fun getClientId ()Ljava/lang/String; + public final fun getClientSequence ()J + public final fun getDetail ()Ljava/lang/String; + public final fun getFailureId ()J + public final fun getGeneration ()I + public final fun getKind ()Lorg/mobilenativefoundation/store6/mutations/MutationFailureKind; + public final fun getMessage ()Ljava/lang/String; + public final fun getOccurredAt ()J +} + +public final class org/mobilenativefoundation/store6/mutations/storage/MutationIntentRecord { + public fun (JILjava/lang/String;JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;I[BLjava/lang/String;J)V + public final fun getArgsBlob ()[B + public final fun getCanonicalId ()Ljava/lang/String; + public final fun getClientId ()Ljava/lang/String; + public final fun getClientSequence ()J + public final fun getCreatedAt ()J + public final fun getIdempotencyRoot ()Ljava/lang/String; + public final fun getMutationId ()Ljava/lang/String; + public final fun getMutatorId ()Ljava/lang/String; + public final fun getMutatorVersion ()I + public final fun getNamespace ()Ljava/lang/String; + public final fun getRecordVersion ()I + public final fun getRowId ()J +} + +public abstract interface class org/mobilenativefoundation/store6/mutations/storage/MutationJournalStorage { + public abstract fun transaction (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class org/mobilenativefoundation/store6/mutations/storage/MutationJournalTransaction { + public abstract fun acks (Ljava/lang/String;)Ljava/util/List; + public abstract fun advanceAlias (Lorg/mobilenativefoundation/store6/mutations/storage/MutationKeyAliasRecord;)V + public abstract fun advanceClient (Lorg/mobilenativefoundation/store6/mutations/storage/MutationClientRecord;)V + public abstract fun advanceEffect (Lorg/mobilenativefoundation/store6/mutations/storage/MutationEffectRecord;)V + public abstract fun advanceExecution (Lorg/mobilenativefoundation/store6/mutations/storage/MutationExecutionRecord;)V + public abstract fun advanceTombstone (Lorg/mobilenativefoundation/store6/mutations/storage/MutationKeyTombstoneRecord;)V + public abstract fun aliases ()Ljava/util/List; + public abstract fun appendFailure (Ljava/lang/String;JILorg/mobilenativefoundation/store6/mutations/MutationFailureKind;Ljava/lang/String;Ljava/lang/String;J)Lorg/mobilenativefoundation/store6/mutations/storage/MutationFailureRecord; + public abstract fun attempts (Ljava/lang/String;)Ljava/util/List; + public abstract fun client (Ljava/lang/String;)Lorg/mobilenativefoundation/store6/mutations/storage/MutationClientRecord; + public abstract fun confirmRetiredThrough (Ljava/lang/String;JJ)Lorg/mobilenativefoundation/store6/mutations/storage/MutationClientRecord; + public abstract fun effects (Ljava/lang/String;)Ljava/util/List; + public abstract fun executions (Ljava/lang/String;)Ljava/util/List; + public abstract fun failures (Ljava/lang/String;)Ljava/util/List; + public abstract fun insertAck (Lorg/mobilenativefoundation/store6/mutations/storage/MutationAckRecord;)V + public abstract fun insertAlias (Lorg/mobilenativefoundation/store6/mutations/storage/MutationKeyAliasRecord;)V + public abstract fun insertAttempt (Lorg/mobilenativefoundation/store6/mutations/storage/MutationAttemptRecord;)V + public abstract fun insertClient (Lorg/mobilenativefoundation/store6/mutations/storage/MutationClientRecord;)V + public abstract fun insertEffect (Lorg/mobilenativefoundation/store6/mutations/storage/MutationEffectRecord;)V + public abstract fun insertExecution (Lorg/mobilenativefoundation/store6/mutations/storage/MutationExecutionRecord;)V + public abstract fun insertIntent (ILjava/lang/String;JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;I[BLjava/lang/String;J)Lorg/mobilenativefoundation/store6/mutations/storage/MutationIntentRecord; + public abstract fun insertTombstone (Lorg/mobilenativefoundation/store6/mutations/storage/MutationKeyTombstoneRecord;)V + public abstract fun intents (Ljava/lang/String;)Ljava/util/List; + public abstract fun prune (Ljava/lang/String;J)V + public abstract fun recordConflictReceipt (Lorg/mobilenativefoundation/store6/mutations/storage/MutationAttemptRecord;)V + public abstract fun tombstones ()Ljava/util/List; +} + +public final class org/mobilenativefoundation/store6/mutations/storage/MutationKeyAliasRecord { + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lorg/mobilenativefoundation/store6/mutations/storage/MutationAliasState;Ljava/lang/String;JJLjava/lang/Long;)V + public final fun getActivatedAt ()Ljava/lang/Long; + public final fun getCreatedAt ()J + public final fun getCreatedByClientId ()Ljava/lang/String; + public final fun getCreatedBySequence ()J + public final fun getSourceCanonicalId ()Ljava/lang/String; + public final fun getSourceNamespace ()Ljava/lang/String; + public final fun getState ()Lorg/mobilenativefoundation/store6/mutations/storage/MutationAliasState; + public final fun getTargetCanonicalId ()Ljava/lang/String; + public final fun getTargetNamespace ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/mutations/storage/MutationKeyTombstoneRecord { + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JLorg/mobilenativefoundation/store6/mutations/storage/MutationTombstoneState;JLjava/lang/Long;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;)V + public final fun getActivatedAt ()Ljava/lang/Long; + public final fun getCanonicalId ()Ljava/lang/String; + public final fun getCreatedAt ()J + public final fun getCreatedByClientId ()Ljava/lang/String; + public final fun getCreatedBySequence ()J + public final fun getNamespace ()Ljava/lang/String; + public final fun getState ()Lorg/mobilenativefoundation/store6/mutations/storage/MutationTombstoneState; + public final fun getSupersededAt ()Ljava/lang/Long; + public final fun getSupersededByClientId ()Ljava/lang/String; + public final fun getSupersededBySequence ()Ljava/lang/Long; +} + +public final class org/mobilenativefoundation/store6/mutations/storage/MutationTombstoneState : java/lang/Enum { + public static final field ACTIVE Lorg/mobilenativefoundation/store6/mutations/storage/MutationTombstoneState; + public static final field PENDING Lorg/mobilenativefoundation/store6/mutations/storage/MutationTombstoneState; + public static final field SUPERSEDED Lorg/mobilenativefoundation/store6/mutations/storage/MutationTombstoneState; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/mobilenativefoundation/store6/mutations/storage/MutationTombstoneState; + public static fun values ()[Lorg/mobilenativefoundation/store6/mutations/storage/MutationTombstoneState; +} + diff --git a/store6-mutations/api/jvm/store6-mutations.api b/store6-mutations/api/jvm/store6-mutations.api new file mode 100644 index 000000000..bfb6f3cb3 --- /dev/null +++ b/store6-mutations/api/jvm/store6-mutations.api @@ -0,0 +1,549 @@ +public final class org/mobilenativefoundation/store6/mutations/DeadLetter { + public final fun getAttempts ()I + public final fun getCanonicalId ()Ljava/lang/String; + public final fun getFailure ()Lorg/mobilenativefoundation/store6/mutations/MutationFailure; + public final fun getGeneration ()I + public final fun getMutationId ()Ljava/lang/String; + public final fun getMutatorId ()Ljava/lang/String; + public final fun getNamespace ()Ljava/lang/String; + public final fun getParkedAtEpochMillis ()J +} + +public final class org/mobilenativefoundation/store6/mutations/MutationAbsentAck : org/mobilenativefoundation/store6/mutations/MutationAck { + public fun (Ljava/lang/String;)V + public fun getEtag ()Ljava/lang/String; +} + +public abstract interface class org/mobilenativefoundation/store6/mutations/MutationAck { + public abstract fun getEtag ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/mutations/MutationAcknowledged : org/mobilenativefoundation/store6/mutations/MutationIntentEvent { + public final fun getGeneration ()I + public fun getIdentity ()Lorg/mobilenativefoundation/store6/mutations/MutationKeyIdentity; + public fun getMutationId ()Ljava/lang/String; + public fun getOccurredAtEpochMillis ()J + public final fun getPresence ()Lorg/mobilenativefoundation/store6/mutations/MutationPresenceState; +} + +public final class org/mobilenativefoundation/store6/mutations/MutationAdopted : org/mobilenativefoundation/store6/mutations/MutationIntentEvent { + public final fun getGeneration ()I + public fun getIdentity ()Lorg/mobilenativefoundation/store6/mutations/MutationKeyIdentity; + public fun getMutationId ()Ljava/lang/String; + public fun getOccurredAtEpochMillis ()J + public final fun getPresence ()Lorg/mobilenativefoundation/store6/mutations/MutationPresenceState; +} + +public final class org/mobilenativefoundation/store6/mutations/MutationAttempted : org/mobilenativefoundation/store6/mutations/MutationIntentEvent { + public final fun getAttempt ()I + public final fun getGeneration ()I + public fun getIdentity ()Lorg/mobilenativefoundation/store6/mutations/MutationKeyIdentity; + public fun getMutationId ()Ljava/lang/String; + public fun getOccurredAtEpochMillis ()J +} + +public final class org/mobilenativefoundation/store6/mutations/MutationCheckpointConfirmed : org/mobilenativefoundation/store6/mutations/MutationEvent { + public final fun getClientId ()Ljava/lang/String; + public final fun getConfirmedThroughSequence ()J + public fun getOccurredAtEpochMillis ()J + public final fun getRequestedThroughSequence ()J +} + +public final class org/mobilenativefoundation/store6/mutations/MutationCheckpointFailed : org/mobilenativefoundation/store6/mutations/MutationEvent { + public final fun getClientId ()Ljava/lang/String; + public final fun getFailure ()Lorg/mobilenativefoundation/store6/mutations/MutationFailure; + public fun getOccurredAtEpochMillis ()J + public final fun getRequestedThroughSequence ()J +} + +public abstract interface class org/mobilenativefoundation/store6/mutations/MutationCodec { + public abstract fun decode (I[B)Ljava/lang/Object; + public abstract fun encode (Ljava/lang/Object;)[B +} + +public final class org/mobilenativefoundation/store6/mutations/MutationConflictBuilder { + public final fun merge (Lkotlin/jvm/functions/Function3;)V + public final fun precondition (Lkotlin/jvm/functions/Function1;)V +} + +public final class org/mobilenativefoundation/store6/mutations/MutationConflictObserved : org/mobilenativefoundation/store6/mutations/MutationIntentEvent { + public final fun getGeneration ()I + public fun getIdentity ()Lorg/mobilenativefoundation/store6/mutations/MutationKeyIdentity; + public fun getMutationId ()Ljava/lang/String; + public fun getOccurredAtEpochMillis ()J + public final fun getServerMeta ()Lorg/mobilenativefoundation/store6/core/StoreMeta; +} + +public abstract interface class org/mobilenativefoundation/store6/mutations/MutationConflictResolution { +} + +public final class org/mobilenativefoundation/store6/mutations/MutationConflictResolution$Retry : org/mobilenativefoundation/store6/mutations/MutationConflictResolution { + public fun (Lorg/mobilenativefoundation/store6/mutations/MutationPresence;)V + public final fun getValue ()Lorg/mobilenativefoundation/store6/mutations/MutationPresence; +} + +public final class org/mobilenativefoundation/store6/mutations/MutationConflictResolution$ServerWins : org/mobilenativefoundation/store6/mutations/MutationConflictResolution { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/mutations/MutationConflictResolution$ServerWins; +} + +public final class org/mobilenativefoundation/store6/mutations/MutationEffectApplied : org/mobilenativefoundation/store6/mutations/MutationIntentEvent { + public final fun getEffectIndex ()I + public final fun getGeneration ()I + public fun getIdentity ()Lorg/mobilenativefoundation/store6/mutations/MutationKeyIdentity; + public fun getMutationId ()Ljava/lang/String; + public fun getOccurredAtEpochMillis ()J +} + +public final class org/mobilenativefoundation/store6/mutations/MutationEffectSkipped : org/mobilenativefoundation/store6/mutations/MutationIntentEvent { + public final fun getEffectIndex ()I + public final fun getGeneration ()I + public fun getIdentity ()Lorg/mobilenativefoundation/store6/mutations/MutationKeyIdentity; + public fun getMutationId ()Ljava/lang/String; + public fun getOccurredAtEpochMillis ()J +} + +public final class org/mobilenativefoundation/store6/mutations/MutationEnqueued : org/mobilenativefoundation/store6/mutations/MutationIntentEvent { + public final fun getClientSequence ()J + public fun getIdentity ()Lorg/mobilenativefoundation/store6/mutations/MutationKeyIdentity; + public fun getMutationId ()Ljava/lang/String; + public final fun getMutatorId ()Ljava/lang/String; + public fun getOccurredAtEpochMillis ()J +} + +public abstract interface class org/mobilenativefoundation/store6/mutations/MutationEvent { + public abstract fun getOccurredAtEpochMillis ()J +} + +public final class org/mobilenativefoundation/store6/mutations/MutationFailed : org/mobilenativefoundation/store6/mutations/MutationIntentEvent { + public final fun getFailure ()Lorg/mobilenativefoundation/store6/mutations/MutationFailure; + public final fun getGeneration ()I + public fun getIdentity ()Lorg/mobilenativefoundation/store6/mutations/MutationKeyIdentity; + public fun getMutationId ()Ljava/lang/String; + public fun getOccurredAtEpochMillis ()J + public final fun getState ()Lorg/mobilenativefoundation/store6/mutations/MutationPendingState; +} + +public final class org/mobilenativefoundation/store6/mutations/MutationFailure { + public final fun getDetail ()Ljava/lang/String; + public final fun getKind ()Lorg/mobilenativefoundation/store6/mutations/MutationFailureKind; + public final fun getMessage ()Ljava/lang/String; + public final fun getOccurredAtEpochMillis ()J +} + +public final class org/mobilenativefoundation/store6/mutations/MutationFailureKind : java/lang/Enum { + public static final field ADOPTION Lorg/mobilenativefoundation/store6/mutations/MutationFailureKind; + public static final field CODEC Lorg/mobilenativefoundation/store6/mutations/MutationFailureKind; + public static final field CONFLICT Lorg/mobilenativefoundation/store6/mutations/MutationFailureKind; + public static final field EFFECT Lorg/mobilenativefoundation/store6/mutations/MutationFailureKind; + public static final field IDENTITY Lorg/mobilenativefoundation/store6/mutations/MutationFailureKind; + public static final field PERSISTENCE Lorg/mobilenativefoundation/store6/mutations/MutationFailureKind; + public static final field PROJECTION Lorg/mobilenativefoundation/store6/mutations/MutationFailureKind; + public static final field PROTOCOL Lorg/mobilenativefoundation/store6/mutations/MutationFailureKind; + public static final field TRANSPORT Lorg/mobilenativefoundation/store6/mutations/MutationFailureKind; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/mobilenativefoundation/store6/mutations/MutationFailureKind; + public static fun values ()[Lorg/mobilenativefoundation/store6/mutations/MutationFailureKind; +} + +public abstract interface class org/mobilenativefoundation/store6/mutations/MutationIntentEvent : org/mobilenativefoundation/store6/mutations/MutationEvent { + public abstract fun getIdentity ()Lorg/mobilenativefoundation/store6/mutations/MutationKeyIdentity; + public abstract fun getMutationId ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/mutations/MutationKeyIdentity { + public final fun getCanonicalId ()Ljava/lang/String; + public final fun getNamespace ()Ljava/lang/String; +} + +public abstract interface class org/mobilenativefoundation/store6/mutations/MutationKeyResolver { + public abstract fun resolve (Lorg/mobilenativefoundation/store6/mutations/MutationKeyIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class org/mobilenativefoundation/store6/mutations/MutationParked : org/mobilenativefoundation/store6/mutations/MutationIntentEvent { + public final fun getFailure ()Lorg/mobilenativefoundation/store6/mutations/MutationFailure; + public final fun getGeneration ()I + public fun getIdentity ()Lorg/mobilenativefoundation/store6/mutations/MutationKeyIdentity; + public fun getMutationId ()Ljava/lang/String; + public fun getOccurredAtEpochMillis ()J +} + +public final class org/mobilenativefoundation/store6/mutations/MutationPendingState : java/lang/Enum { + public static final field ADOPTING Lorg/mobilenativefoundation/store6/mutations/MutationPendingState; + public static final field APPLYING_EFFECTS Lorg/mobilenativefoundation/store6/mutations/MutationPendingState; + public static final field INFLIGHT Lorg/mobilenativefoundation/store6/mutations/MutationPendingState; + public static final field PENDING Lorg/mobilenativefoundation/store6/mutations/MutationPendingState; + public static final field REFRESHING Lorg/mobilenativefoundation/store6/mutations/MutationPendingState; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/mobilenativefoundation/store6/mutations/MutationPendingState; + public static fun values ()[Lorg/mobilenativefoundation/store6/mutations/MutationPendingState; +} + +public final class org/mobilenativefoundation/store6/mutations/MutationPreconditionCandidate { + public final fun getBase ()Lorg/mobilenativefoundation/store6/mutations/MutationPresence; + public final fun getCapturedMeta ()Lorg/mobilenativefoundation/store6/core/StoreMeta; + public final fun getGeneration ()I + public final fun getIdentity ()Lorg/mobilenativefoundation/store6/mutations/MutationKeyIdentity; + public final fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; + public final fun getMine ()Lorg/mobilenativefoundation/store6/mutations/MutationPresence; + public final fun getMutationId ()Ljava/lang/String; +} + +public abstract interface class org/mobilenativefoundation/store6/mutations/MutationPresence { +} + +public final class org/mobilenativefoundation/store6/mutations/MutationPresence$Absent : org/mobilenativefoundation/store6/mutations/MutationPresence { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/mutations/MutationPresence$Absent; +} + +public final class org/mobilenativefoundation/store6/mutations/MutationPresence$Present : org/mobilenativefoundation/store6/mutations/MutationPresence { + public fun (Ljava/lang/Object;)V + public final fun getValue ()Ljava/lang/Object; +} + +public final class org/mobilenativefoundation/store6/mutations/MutationPresenceState : java/lang/Enum { + public static final field ABSENT Lorg/mobilenativefoundation/store6/mutations/MutationPresenceState; + public static final field PRESENT Lorg/mobilenativefoundation/store6/mutations/MutationPresenceState; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/mobilenativefoundation/store6/mutations/MutationPresenceState; + public static fun values ()[Lorg/mobilenativefoundation/store6/mutations/MutationPresenceState; +} + +public final class org/mobilenativefoundation/store6/mutations/MutationPresentAck : org/mobilenativefoundation/store6/mutations/MutationAck { + public fun (Ljava/lang/Object;Ljava/lang/String;Lorg/mobilenativefoundation/store6/core/StoreKey;)V + public final fun getAuthoritative ()Ljava/lang/Object; + public final fun getCanonicalKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; + public fun getEtag ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/mutations/MutationPush { + public final fun getBase ()Lorg/mobilenativefoundation/store6/mutations/MutationPresence; + public final fun getBaseMeta ()Lorg/mobilenativefoundation/store6/core/StoreMeta; + public final fun getClientId ()Ljava/lang/String; + public final fun getClientSequence ()J + public final fun getGeneration ()I + public final fun getIdempotencyKey ()Ljava/lang/String; + public final fun getIdentity ()Lorg/mobilenativefoundation/store6/mutations/MutationKeyIdentity; + public final fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; + public final fun getMine ()Lorg/mobilenativefoundation/store6/mutations/MutationPresence; + public final fun getMutationId ()Ljava/lang/String; + public final fun getRetiredThroughSequence ()J + public final fun getValueCodecVersion ()I +} + +public final class org/mobilenativefoundation/store6/mutations/MutationRetired : org/mobilenativefoundation/store6/mutations/MutationIntentEvent { + public final fun getGeneration ()I + public fun getIdentity ()Lorg/mobilenativefoundation/store6/mutations/MutationKeyIdentity; + public fun getMutationId ()Ljava/lang/String; + public fun getOccurredAtEpochMillis ()J + public final fun getRetiredThroughSequence ()J +} + +public final class org/mobilenativefoundation/store6/mutations/MutationRetirement { + public final fun getClientId ()Ljava/lang/String; + public final fun getRetiredThroughSequence ()J +} + +public final class org/mobilenativefoundation/store6/mutations/MutationRetirementAck { + public fun (J)V + public final fun getConfirmedThroughSequence ()J +} + +public abstract interface class org/mobilenativefoundation/store6/mutations/MutationServer { + public abstract fun push (Lorg/mobilenativefoundation/store6/mutations/MutationPush;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun retire (Lorg/mobilenativefoundation/store6/mutations/MutationRetirement;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class org/mobilenativefoundation/store6/mutations/MutationStore : org/mobilenativefoundation/store6/core/Store { + public fun clear (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun clearAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun clearNamespace (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun close ()V + public final fun deadLetters (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun drain (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun drain (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun get (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Freshness;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun getEvents ()Lkotlinx/coroutines/flow/SharedFlow; + public final fun getKeyEvents ()Lkotlinx/coroutines/flow/Flow; + public final fun getPoisoned ()Lkotlinx/coroutines/flow/SharedFlow; + public fun invalidate (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun invalidateAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun invalidateNamespace (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun mutate (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/mutations/MutatorRef;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun pending (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun pendingWrites (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun stream (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Freshness;)Lkotlinx/coroutines/flow/Flow; +} + +public final class org/mobilenativefoundation/store6/mutations/MutationStoreBuilder { + public final fun bookkeeper (Lorg/mobilenativefoundation/store6/core/seam/Bookkeeper;)V + public final fun conflicts (Lkotlin/jvm/functions/Function1;)V + public final fun fetcher (Lkotlin/jvm/functions/Function2;)V + public final fun fetcher (Lorg/mobilenativefoundation/store6/core/seam/Fetcher;)V + public final fun fetcherOfResult (Lkotlin/jvm/functions/Function2;)V + public final fun freshnessValidator (Lorg/mobilenativefoundation/store6/core/seam/FreshnessValidator;)V + public final fun journalStorage (Lorg/mobilenativefoundation/store6/mutations/storage/MutationJournalStorage;)V + public final fun maxIdleKeys (I)V + public final fun persistence (Lorg/mobilenativefoundation/store6/core/seam/SourceOfTruth;)V + public final fun telemetry (Lorg/mobilenativefoundation/store6/core/seam/StoreTelemetry;)V + public final fun wallClock (Lorg/mobilenativefoundation/store6/core/seam/WallClock;)V +} + +public final class org/mobilenativefoundation/store6/mutations/MutationStoreKt { + public static final fun mutationStore (Lorg/mobilenativefoundation/store6/mutations/MutatorRegistry;Lorg/mobilenativefoundation/store6/mutations/MutationServer;Lorg/mobilenativefoundation/store6/mutations/MutationKeyResolver;ILorg/mobilenativefoundation/store6/mutations/MutationCodec;Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store6/mutations/MutationStore; +} + +public final class org/mobilenativefoundation/store6/mutations/MutatorRef { + public final fun getId ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/mutations/MutatorRegistry { +} + +public final class org/mobilenativefoundation/store6/mutations/MutatorRegistryBuilder { + public final fun create (Ljava/lang/String;ILorg/mobilenativefoundation/store6/mutations/MutationCodec;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store6/mutations/MutatorRef; + public final fun delete (Ljava/lang/String;Lkotlin/jvm/functions/Function2;)Lorg/mobilenativefoundation/store6/mutations/MutatorRef; + public final fun mutator (Ljava/lang/String;ILorg/mobilenativefoundation/store6/mutations/MutationCodec;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;)Lorg/mobilenativefoundation/store6/mutations/MutatorRef; + public final fun update (Ljava/lang/String;ILorg/mobilenativefoundation/store6/mutations/MutationCodec;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;)Lorg/mobilenativefoundation/store6/mutations/MutatorRef; + public final fun upsert (Ljava/lang/String;ILorg/mobilenativefoundation/store6/mutations/MutationCodec;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;)Lorg/mobilenativefoundation/store6/mutations/MutatorRef; +} + +public final class org/mobilenativefoundation/store6/mutations/MutatorRegistryKt { + public static final fun mutatorRegistry (Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store6/mutations/MutatorRegistry; +} + +public final class org/mobilenativefoundation/store6/mutations/PendingIntent { + public final fun getAttempt ()I + public final fun getCanonicalId ()Ljava/lang/String; + public final fun getCreatedAtEpochMillis ()J + public final fun getMutationId ()Ljava/lang/String; + public final fun getMutatorId ()Ljava/lang/String; + public final fun getNamespace ()Ljava/lang/String; + public final fun getState ()Lorg/mobilenativefoundation/store6/mutations/MutationPendingState; +} + +public final class org/mobilenativefoundation/store6/mutations/PoisonedIntent { + public final fun getFailure ()Ljava/lang/Throwable; + public final fun getMutationId ()Ljava/lang/String; + public final fun getMutatorId ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/mutations/StaleSet { + public fun (Ljava/util/Set;Ljava/util/Set;)V + public final fun getKeys ()Ljava/util/Set; + public final fun getNamespaces ()Ljava/util/Set; +} + +public final class org/mobilenativefoundation/store6/mutations/storage/InMemoryMutationJournalStorage : org/mobilenativefoundation/store6/mutations/storage/MutationJournalStorage { + public fun ()V + public fun transaction (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class org/mobilenativefoundation/store6/mutations/storage/MutationAckRecord { + public fun (Ljava/lang/String;JILorg/mobilenativefoundation/store6/mutations/MutationPresenceState;[BILjava/lang/String;Ljava/lang/String;Ljava/lang/String;J)V + public final fun getAuthoritativeBlob ()[B + public final fun getAuthoritativePresence ()Lorg/mobilenativefoundation/store6/mutations/MutationPresenceState; + public final fun getCanonicalTargetId ()Ljava/lang/String; + public final fun getCanonicalTargetNamespace ()Ljava/lang/String; + public final fun getClientId ()Ljava/lang/String; + public final fun getClientSequence ()J + public final fun getEtag ()Ljava/lang/String; + public final fun getGeneration ()I + public final fun getReceivedAt ()J + public final fun getValueCodecVersion ()I +} + +public final class org/mobilenativefoundation/store6/mutations/storage/MutationAliasState : java/lang/Enum { + public static final field ACTIVE Lorg/mobilenativefoundation/store6/mutations/storage/MutationAliasState; + public static final field PENDING Lorg/mobilenativefoundation/store6/mutations/storage/MutationAliasState; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/mobilenativefoundation/store6/mutations/storage/MutationAliasState; + public static fun values ()[Lorg/mobilenativefoundation/store6/mutations/storage/MutationAliasState; +} + +public final class org/mobilenativefoundation/store6/mutations/storage/MutationAttemptRecord { + public fun (Ljava/lang/String;JILjava/lang/String;Ljava/lang/String;ILorg/mobilenativefoundation/store6/mutations/MutationPresenceState;[BLorg/mobilenativefoundation/store6/mutations/MutationPresenceState;[BZLjava/lang/Long;Ljava/lang/String;JLjava/lang/String;JLjava/lang/Boolean;Ljava/lang/Long;Ljava/lang/String;Ljava/lang/Long;)V + public final fun getAdvertisedRetiredThroughSequence ()J + public final fun getBaseBlob ()[B + public final fun getBasePresence ()Lorg/mobilenativefoundation/store6/mutations/MutationPresenceState; + public final fun getClientId ()Ljava/lang/String; + public final fun getClientSequence ()J + public final fun getConflictEtag ()Ljava/lang/String; + public final fun getConflictMetaPresent ()Ljava/lang/Boolean; + public final fun getConflictReceivedAt ()Ljava/lang/Long; + public final fun getConflictWrittenAt ()Ljava/lang/Long; + public final fun getEffectiveCanonicalId ()Ljava/lang/String; + public final fun getEffectiveNamespace ()Ljava/lang/String; + public final fun getGeneration ()I + public final fun getGenerationIdempotencyKey ()Ljava/lang/String; + public final fun getMineBlob ()[B + public final fun getMinePresence ()Lorg/mobilenativefoundation/store6/mutations/MutationPresenceState; + public final fun getPreconditionEtag ()Ljava/lang/String; + public final fun getPreconditionMetaPresent ()Z + public final fun getPreconditionWrittenAt ()Ljava/lang/Long; + public final fun getPreparedAt ()J + public final fun getValueCodecVersion ()I +} + +public final class org/mobilenativefoundation/store6/mutations/storage/MutationClientRecord { + public fun (ILjava/lang/String;JJJJ)V + public final fun getClientId ()Ljava/lang/String; + public final fun getCreatedAt ()J + public final fun getLastAllocatedSequence ()J + public final fun getRecordVersion ()I + public final fun getRetiredThroughSequence ()J + public final fun getServerConfirmedRetiredThroughSequence ()J +} + +public final class org/mobilenativefoundation/store6/mutations/storage/MutationEffectDisposition : java/lang/Enum { + public static final field APPLIED Lorg/mobilenativefoundation/store6/mutations/storage/MutationEffectDisposition; + public static final field PENDING Lorg/mobilenativefoundation/store6/mutations/storage/MutationEffectDisposition; + public static final field SKIPPED Lorg/mobilenativefoundation/store6/mutations/storage/MutationEffectDisposition; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/mobilenativefoundation/store6/mutations/storage/MutationEffectDisposition; + public static fun values ()[Lorg/mobilenativefoundation/store6/mutations/storage/MutationEffectDisposition; +} + +public final class org/mobilenativefoundation/store6/mutations/storage/MutationEffectKind : java/lang/Enum { + public static final field KEY Lorg/mobilenativefoundation/store6/mutations/storage/MutationEffectKind; + public static final field NAMESPACE Lorg/mobilenativefoundation/store6/mutations/storage/MutationEffectKind; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/mobilenativefoundation/store6/mutations/storage/MutationEffectKind; + public static fun values ()[Lorg/mobilenativefoundation/store6/mutations/storage/MutationEffectKind; +} + +public final class org/mobilenativefoundation/store6/mutations/storage/MutationEffectRecord { + public fun (Ljava/lang/String;JILorg/mobilenativefoundation/store6/mutations/storage/MutationEffectKind;Ljava/lang/String;Ljava/lang/String;JLorg/mobilenativefoundation/store6/mutations/storage/MutationEffectDisposition;Ljava/lang/Long;)V + public final fun getCanonicalId ()Ljava/lang/String; + public final fun getClientId ()Ljava/lang/String; + public final fun getClientSequence ()J + public final fun getCompletedAt ()Ljava/lang/Long; + public final fun getCreatedAt ()J + public final fun getDisposition ()Lorg/mobilenativefoundation/store6/mutations/storage/MutationEffectDisposition; + public final fun getEffectIndex ()I + public final fun getKind ()Lorg/mobilenativefoundation/store6/mutations/storage/MutationEffectKind; + public final fun getNamespace ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/mutations/storage/MutationExecutionPhase : java/lang/Enum { + public static final field ACKED Lorg/mobilenativefoundation/store6/mutations/storage/MutationExecutionPhase; + public static final field EFFECTS_PENDING Lorg/mobilenativefoundation/store6/mutations/storage/MutationExecutionPhase; + public static final field INFLIGHT Lorg/mobilenativefoundation/store6/mutations/storage/MutationExecutionPhase; + public static final field PARKED Lorg/mobilenativefoundation/store6/mutations/storage/MutationExecutionPhase; + public static final field READY Lorg/mobilenativefoundation/store6/mutations/storage/MutationExecutionPhase; + public static final field REFRESH_REQUIRED Lorg/mobilenativefoundation/store6/mutations/storage/MutationExecutionPhase; + public static final field RETIRED Lorg/mobilenativefoundation/store6/mutations/storage/MutationExecutionPhase; + public static final field UNPREPARED Lorg/mobilenativefoundation/store6/mutations/storage/MutationExecutionPhase; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/mobilenativefoundation/store6/mutations/storage/MutationExecutionPhase; + public static fun values ()[Lorg/mobilenativefoundation/store6/mutations/storage/MutationExecutionPhase; +} + +public final class org/mobilenativefoundation/store6/mutations/storage/MutationExecutionRecord { + public fun (Ljava/lang/String;JLorg/mobilenativefoundation/store6/mutations/storage/MutationExecutionPhase;IILjava/lang/Long;Ljava/lang/Long;Ljava/lang/Long;)V + public final fun getActiveFailureId ()Ljava/lang/Long; + public final fun getAttempt ()I + public final fun getClientId ()Ljava/lang/String; + public final fun getClientSequence ()J + public final fun getCurrentGeneration ()I + public final fun getLastAttemptAt ()Ljava/lang/Long; + public final fun getPhase ()Lorg/mobilenativefoundation/store6/mutations/storage/MutationExecutionPhase; + public final fun getRetiredAt ()Ljava/lang/Long; +} + +public final class org/mobilenativefoundation/store6/mutations/storage/MutationFailureRecord { + public fun (JLjava/lang/String;JILorg/mobilenativefoundation/store6/mutations/MutationFailureKind;Ljava/lang/String;Ljava/lang/String;J)V + public final fun getClientId ()Ljava/lang/String; + public final fun getClientSequence ()J + public final fun getDetail ()Ljava/lang/String; + public final fun getFailureId ()J + public final fun getGeneration ()I + public final fun getKind ()Lorg/mobilenativefoundation/store6/mutations/MutationFailureKind; + public final fun getMessage ()Ljava/lang/String; + public final fun getOccurredAt ()J +} + +public final class org/mobilenativefoundation/store6/mutations/storage/MutationIntentRecord { + public fun (JILjava/lang/String;JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;I[BLjava/lang/String;J)V + public final fun getArgsBlob ()[B + public final fun getCanonicalId ()Ljava/lang/String; + public final fun getClientId ()Ljava/lang/String; + public final fun getClientSequence ()J + public final fun getCreatedAt ()J + public final fun getIdempotencyRoot ()Ljava/lang/String; + public final fun getMutationId ()Ljava/lang/String; + public final fun getMutatorId ()Ljava/lang/String; + public final fun getMutatorVersion ()I + public final fun getNamespace ()Ljava/lang/String; + public final fun getRecordVersion ()I + public final fun getRowId ()J +} + +public abstract interface class org/mobilenativefoundation/store6/mutations/storage/MutationJournalStorage { + public abstract fun transaction (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class org/mobilenativefoundation/store6/mutations/storage/MutationJournalTransaction { + public abstract fun acks (Ljava/lang/String;)Ljava/util/List; + public abstract fun advanceAlias (Lorg/mobilenativefoundation/store6/mutations/storage/MutationKeyAliasRecord;)V + public abstract fun advanceClient (Lorg/mobilenativefoundation/store6/mutations/storage/MutationClientRecord;)V + public abstract fun advanceEffect (Lorg/mobilenativefoundation/store6/mutations/storage/MutationEffectRecord;)V + public abstract fun advanceExecution (Lorg/mobilenativefoundation/store6/mutations/storage/MutationExecutionRecord;)V + public abstract fun advanceTombstone (Lorg/mobilenativefoundation/store6/mutations/storage/MutationKeyTombstoneRecord;)V + public abstract fun aliases ()Ljava/util/List; + public abstract fun appendFailure (Ljava/lang/String;JILorg/mobilenativefoundation/store6/mutations/MutationFailureKind;Ljava/lang/String;Ljava/lang/String;J)Lorg/mobilenativefoundation/store6/mutations/storage/MutationFailureRecord; + public abstract fun attempts (Ljava/lang/String;)Ljava/util/List; + public abstract fun client (Ljava/lang/String;)Lorg/mobilenativefoundation/store6/mutations/storage/MutationClientRecord; + public abstract fun confirmRetiredThrough (Ljava/lang/String;JJ)Lorg/mobilenativefoundation/store6/mutations/storage/MutationClientRecord; + public abstract fun effects (Ljava/lang/String;)Ljava/util/List; + public abstract fun executions (Ljava/lang/String;)Ljava/util/List; + public abstract fun failures (Ljava/lang/String;)Ljava/util/List; + public abstract fun insertAck (Lorg/mobilenativefoundation/store6/mutations/storage/MutationAckRecord;)V + public abstract fun insertAlias (Lorg/mobilenativefoundation/store6/mutations/storage/MutationKeyAliasRecord;)V + public abstract fun insertAttempt (Lorg/mobilenativefoundation/store6/mutations/storage/MutationAttemptRecord;)V + public abstract fun insertClient (Lorg/mobilenativefoundation/store6/mutations/storage/MutationClientRecord;)V + public abstract fun insertEffect (Lorg/mobilenativefoundation/store6/mutations/storage/MutationEffectRecord;)V + public abstract fun insertExecution (Lorg/mobilenativefoundation/store6/mutations/storage/MutationExecutionRecord;)V + public abstract fun insertIntent (ILjava/lang/String;JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;I[BLjava/lang/String;J)Lorg/mobilenativefoundation/store6/mutations/storage/MutationIntentRecord; + public abstract fun insertTombstone (Lorg/mobilenativefoundation/store6/mutations/storage/MutationKeyTombstoneRecord;)V + public abstract fun intents (Ljava/lang/String;)Ljava/util/List; + public abstract fun prune (Ljava/lang/String;J)V + public abstract fun recordConflictReceipt (Lorg/mobilenativefoundation/store6/mutations/storage/MutationAttemptRecord;)V + public abstract fun tombstones ()Ljava/util/List; +} + +public final class org/mobilenativefoundation/store6/mutations/storage/MutationKeyAliasRecord { + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lorg/mobilenativefoundation/store6/mutations/storage/MutationAliasState;Ljava/lang/String;JJLjava/lang/Long;)V + public final fun getActivatedAt ()Ljava/lang/Long; + public final fun getCreatedAt ()J + public final fun getCreatedByClientId ()Ljava/lang/String; + public final fun getCreatedBySequence ()J + public final fun getSourceCanonicalId ()Ljava/lang/String; + public final fun getSourceNamespace ()Ljava/lang/String; + public final fun getState ()Lorg/mobilenativefoundation/store6/mutations/storage/MutationAliasState; + public final fun getTargetCanonicalId ()Ljava/lang/String; + public final fun getTargetNamespace ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/mutations/storage/MutationKeyTombstoneRecord { + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JLorg/mobilenativefoundation/store6/mutations/storage/MutationTombstoneState;JLjava/lang/Long;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;)V + public final fun getActivatedAt ()Ljava/lang/Long; + public final fun getCanonicalId ()Ljava/lang/String; + public final fun getCreatedAt ()J + public final fun getCreatedByClientId ()Ljava/lang/String; + public final fun getCreatedBySequence ()J + public final fun getNamespace ()Ljava/lang/String; + public final fun getState ()Lorg/mobilenativefoundation/store6/mutations/storage/MutationTombstoneState; + public final fun getSupersededAt ()Ljava/lang/Long; + public final fun getSupersededByClientId ()Ljava/lang/String; + public final fun getSupersededBySequence ()Ljava/lang/Long; +} + +public final class org/mobilenativefoundation/store6/mutations/storage/MutationTombstoneState : java/lang/Enum { + public static final field ACTIVE Lorg/mobilenativefoundation/store6/mutations/storage/MutationTombstoneState; + public static final field PENDING Lorg/mobilenativefoundation/store6/mutations/storage/MutationTombstoneState; + public static final field SUPERSEDED Lorg/mobilenativefoundation/store6/mutations/storage/MutationTombstoneState; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/mobilenativefoundation/store6/mutations/storage/MutationTombstoneState; + public static fun values ()[Lorg/mobilenativefoundation/store6/mutations/storage/MutationTombstoneState; +} + diff --git a/store6-mutations/api/store6-mutations.klib.api b/store6-mutations/api/store6-mutations.klib.api new file mode 100644 index 000000000..a85d77503 --- /dev/null +++ b/store6-mutations/api/store6-mutations.klib.api @@ -0,0 +1,798 @@ +// Klib ABI Dump +// Targets: [iosArm64, iosSimulatorArm64, iosX64, js, linuxX64, macosArm64, mingwX64, tvosArm64, wasmJs, watchosArm64] +// Rendering settings: +// - Signature version: 2 +// - Show manifest properties: true +// - Show declarations: true + +// Library unique name: +final enum class org.mobilenativefoundation.store6.mutations.storage/MutationAliasState : kotlin/Enum { // org.mobilenativefoundation.store6.mutations.storage/MutationAliasState|null[0] + enum entry ACTIVE // org.mobilenativefoundation.store6.mutations.storage/MutationAliasState.ACTIVE|null[0] + enum entry PENDING // org.mobilenativefoundation.store6.mutations.storage/MutationAliasState.PENDING|null[0] + + final val entries // org.mobilenativefoundation.store6.mutations.storage/MutationAliasState.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // org.mobilenativefoundation.store6.mutations.storage/MutationAliasState.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): org.mobilenativefoundation.store6.mutations.storage/MutationAliasState // org.mobilenativefoundation.store6.mutations.storage/MutationAliasState.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // org.mobilenativefoundation.store6.mutations.storage/MutationAliasState.values|values#static(){}[0] +} + +final enum class org.mobilenativefoundation.store6.mutations.storage/MutationEffectDisposition : kotlin/Enum { // org.mobilenativefoundation.store6.mutations.storage/MutationEffectDisposition|null[0] + enum entry APPLIED // org.mobilenativefoundation.store6.mutations.storage/MutationEffectDisposition.APPLIED|null[0] + enum entry PENDING // org.mobilenativefoundation.store6.mutations.storage/MutationEffectDisposition.PENDING|null[0] + enum entry SKIPPED // org.mobilenativefoundation.store6.mutations.storage/MutationEffectDisposition.SKIPPED|null[0] + + final val entries // org.mobilenativefoundation.store6.mutations.storage/MutationEffectDisposition.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // org.mobilenativefoundation.store6.mutations.storage/MutationEffectDisposition.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): org.mobilenativefoundation.store6.mutations.storage/MutationEffectDisposition // org.mobilenativefoundation.store6.mutations.storage/MutationEffectDisposition.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // org.mobilenativefoundation.store6.mutations.storage/MutationEffectDisposition.values|values#static(){}[0] +} + +final enum class org.mobilenativefoundation.store6.mutations.storage/MutationEffectKind : kotlin/Enum { // org.mobilenativefoundation.store6.mutations.storage/MutationEffectKind|null[0] + enum entry KEY // org.mobilenativefoundation.store6.mutations.storage/MutationEffectKind.KEY|null[0] + enum entry NAMESPACE // org.mobilenativefoundation.store6.mutations.storage/MutationEffectKind.NAMESPACE|null[0] + + final val entries // org.mobilenativefoundation.store6.mutations.storage/MutationEffectKind.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // org.mobilenativefoundation.store6.mutations.storage/MutationEffectKind.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): org.mobilenativefoundation.store6.mutations.storage/MutationEffectKind // org.mobilenativefoundation.store6.mutations.storage/MutationEffectKind.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // org.mobilenativefoundation.store6.mutations.storage/MutationEffectKind.values|values#static(){}[0] +} + +final enum class org.mobilenativefoundation.store6.mutations.storage/MutationExecutionPhase : kotlin/Enum { // org.mobilenativefoundation.store6.mutations.storage/MutationExecutionPhase|null[0] + enum entry ACKED // org.mobilenativefoundation.store6.mutations.storage/MutationExecutionPhase.ACKED|null[0] + enum entry EFFECTS_PENDING // org.mobilenativefoundation.store6.mutations.storage/MutationExecutionPhase.EFFECTS_PENDING|null[0] + enum entry INFLIGHT // org.mobilenativefoundation.store6.mutations.storage/MutationExecutionPhase.INFLIGHT|null[0] + enum entry PARKED // org.mobilenativefoundation.store6.mutations.storage/MutationExecutionPhase.PARKED|null[0] + enum entry READY // org.mobilenativefoundation.store6.mutations.storage/MutationExecutionPhase.READY|null[0] + enum entry REFRESH_REQUIRED // org.mobilenativefoundation.store6.mutations.storage/MutationExecutionPhase.REFRESH_REQUIRED|null[0] + enum entry RETIRED // org.mobilenativefoundation.store6.mutations.storage/MutationExecutionPhase.RETIRED|null[0] + enum entry UNPREPARED // org.mobilenativefoundation.store6.mutations.storage/MutationExecutionPhase.UNPREPARED|null[0] + + final val entries // org.mobilenativefoundation.store6.mutations.storage/MutationExecutionPhase.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // org.mobilenativefoundation.store6.mutations.storage/MutationExecutionPhase.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): org.mobilenativefoundation.store6.mutations.storage/MutationExecutionPhase // org.mobilenativefoundation.store6.mutations.storage/MutationExecutionPhase.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // org.mobilenativefoundation.store6.mutations.storage/MutationExecutionPhase.values|values#static(){}[0] +} + +final enum class org.mobilenativefoundation.store6.mutations.storage/MutationTombstoneState : kotlin/Enum { // org.mobilenativefoundation.store6.mutations.storage/MutationTombstoneState|null[0] + enum entry ACTIVE // org.mobilenativefoundation.store6.mutations.storage/MutationTombstoneState.ACTIVE|null[0] + enum entry PENDING // org.mobilenativefoundation.store6.mutations.storage/MutationTombstoneState.PENDING|null[0] + enum entry SUPERSEDED // org.mobilenativefoundation.store6.mutations.storage/MutationTombstoneState.SUPERSEDED|null[0] + + final val entries // org.mobilenativefoundation.store6.mutations.storage/MutationTombstoneState.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // org.mobilenativefoundation.store6.mutations.storage/MutationTombstoneState.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): org.mobilenativefoundation.store6.mutations.storage/MutationTombstoneState // org.mobilenativefoundation.store6.mutations.storage/MutationTombstoneState.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // org.mobilenativefoundation.store6.mutations.storage/MutationTombstoneState.values|values#static(){}[0] +} + +final enum class org.mobilenativefoundation.store6.mutations/MutationFailureKind : kotlin/Enum { // org.mobilenativefoundation.store6.mutations/MutationFailureKind|null[0] + enum entry ADOPTION // org.mobilenativefoundation.store6.mutations/MutationFailureKind.ADOPTION|null[0] + enum entry CODEC // org.mobilenativefoundation.store6.mutations/MutationFailureKind.CODEC|null[0] + enum entry CONFLICT // org.mobilenativefoundation.store6.mutations/MutationFailureKind.CONFLICT|null[0] + enum entry EFFECT // org.mobilenativefoundation.store6.mutations/MutationFailureKind.EFFECT|null[0] + enum entry IDENTITY // org.mobilenativefoundation.store6.mutations/MutationFailureKind.IDENTITY|null[0] + enum entry PERSISTENCE // org.mobilenativefoundation.store6.mutations/MutationFailureKind.PERSISTENCE|null[0] + enum entry PROJECTION // org.mobilenativefoundation.store6.mutations/MutationFailureKind.PROJECTION|null[0] + enum entry PROTOCOL // org.mobilenativefoundation.store6.mutations/MutationFailureKind.PROTOCOL|null[0] + enum entry TRANSPORT // org.mobilenativefoundation.store6.mutations/MutationFailureKind.TRANSPORT|null[0] + + final val entries // org.mobilenativefoundation.store6.mutations/MutationFailureKind.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // org.mobilenativefoundation.store6.mutations/MutationFailureKind.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): org.mobilenativefoundation.store6.mutations/MutationFailureKind // org.mobilenativefoundation.store6.mutations/MutationFailureKind.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // org.mobilenativefoundation.store6.mutations/MutationFailureKind.values|values#static(){}[0] +} + +final enum class org.mobilenativefoundation.store6.mutations/MutationPendingState : kotlin/Enum { // org.mobilenativefoundation.store6.mutations/MutationPendingState|null[0] + enum entry ADOPTING // org.mobilenativefoundation.store6.mutations/MutationPendingState.ADOPTING|null[0] + enum entry APPLYING_EFFECTS // org.mobilenativefoundation.store6.mutations/MutationPendingState.APPLYING_EFFECTS|null[0] + enum entry INFLIGHT // org.mobilenativefoundation.store6.mutations/MutationPendingState.INFLIGHT|null[0] + enum entry PENDING // org.mobilenativefoundation.store6.mutations/MutationPendingState.PENDING|null[0] + enum entry REFRESHING // org.mobilenativefoundation.store6.mutations/MutationPendingState.REFRESHING|null[0] + + final val entries // org.mobilenativefoundation.store6.mutations/MutationPendingState.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // org.mobilenativefoundation.store6.mutations/MutationPendingState.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): org.mobilenativefoundation.store6.mutations/MutationPendingState // org.mobilenativefoundation.store6.mutations/MutationPendingState.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // org.mobilenativefoundation.store6.mutations/MutationPendingState.values|values#static(){}[0] +} + +final enum class org.mobilenativefoundation.store6.mutations/MutationPresenceState : kotlin/Enum { // org.mobilenativefoundation.store6.mutations/MutationPresenceState|null[0] + enum entry ABSENT // org.mobilenativefoundation.store6.mutations/MutationPresenceState.ABSENT|null[0] + enum entry PRESENT // org.mobilenativefoundation.store6.mutations/MutationPresenceState.PRESENT|null[0] + + final val entries // org.mobilenativefoundation.store6.mutations/MutationPresenceState.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // org.mobilenativefoundation.store6.mutations/MutationPresenceState.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): org.mobilenativefoundation.store6.mutations/MutationPresenceState // org.mobilenativefoundation.store6.mutations/MutationPresenceState.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // org.mobilenativefoundation.store6.mutations/MutationPresenceState.values|values#static(){}[0] +} + +abstract fun interface <#A: org.mobilenativefoundation.store6.core/StoreKey> org.mobilenativefoundation.store6.mutations/MutationKeyResolver { // org.mobilenativefoundation.store6.mutations/MutationKeyResolver|null[0] + abstract suspend fun resolve(org.mobilenativefoundation.store6.mutations/MutationKeyIdentity): #A? // org.mobilenativefoundation.store6.mutations/MutationKeyResolver.resolve|resolve(org.mobilenativefoundation.store6.mutations.MutationKeyIdentity){}[0] +} + +abstract interface <#A: kotlin/Any> org.mobilenativefoundation.store6.mutations/MutationCodec { // org.mobilenativefoundation.store6.mutations/MutationCodec|null[0] + abstract fun decode(kotlin/Int, kotlin/ByteArray): #A // org.mobilenativefoundation.store6.mutations/MutationCodec.decode|decode(kotlin.Int;kotlin.ByteArray){}[0] + abstract fun encode(#A): kotlin/ByteArray // org.mobilenativefoundation.store6.mutations/MutationCodec.encode|encode(1:0){}[0] +} + +abstract interface <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> org.mobilenativefoundation.store6.mutations/MutationServer { // org.mobilenativefoundation.store6.mutations/MutationServer|null[0] + abstract suspend fun push(org.mobilenativefoundation.store6.mutations/MutationPush<#A, #B>): org.mobilenativefoundation.store6.mutations/MutationAck<#A, #B> // org.mobilenativefoundation.store6.mutations/MutationServer.push|push(org.mobilenativefoundation.store6.mutations.MutationPush<1:0,1:1>){}[0] + abstract suspend fun retire(org.mobilenativefoundation.store6.mutations/MutationRetirement): org.mobilenativefoundation.store6.mutations/MutationRetirementAck // org.mobilenativefoundation.store6.mutations/MutationServer.retire|retire(org.mobilenativefoundation.store6.mutations.MutationRetirement){}[0] +} + +abstract interface org.mobilenativefoundation.store6.mutations.storage/MutationJournalStorage { // org.mobilenativefoundation.store6.mutations.storage/MutationJournalStorage|null[0] + abstract suspend fun <#A1: kotlin/Any?> transaction(kotlin/Function1): #A1 // org.mobilenativefoundation.store6.mutations.storage/MutationJournalStorage.transaction|transaction(kotlin.Function1){0§}[0] +} + +abstract interface org.mobilenativefoundation.store6.mutations.storage/MutationJournalTransaction { // org.mobilenativefoundation.store6.mutations.storage/MutationJournalTransaction|null[0] + abstract fun acks(kotlin/String): kotlin.collections/List // org.mobilenativefoundation.store6.mutations.storage/MutationJournalTransaction.acks|acks(kotlin.String){}[0] + abstract fun advanceAlias(org.mobilenativefoundation.store6.mutations.storage/MutationKeyAliasRecord) // org.mobilenativefoundation.store6.mutations.storage/MutationJournalTransaction.advanceAlias|advanceAlias(org.mobilenativefoundation.store6.mutations.storage.MutationKeyAliasRecord){}[0] + abstract fun advanceClient(org.mobilenativefoundation.store6.mutations.storage/MutationClientRecord) // org.mobilenativefoundation.store6.mutations.storage/MutationJournalTransaction.advanceClient|advanceClient(org.mobilenativefoundation.store6.mutations.storage.MutationClientRecord){}[0] + abstract fun advanceEffect(org.mobilenativefoundation.store6.mutations.storage/MutationEffectRecord) // org.mobilenativefoundation.store6.mutations.storage/MutationJournalTransaction.advanceEffect|advanceEffect(org.mobilenativefoundation.store6.mutations.storage.MutationEffectRecord){}[0] + abstract fun advanceExecution(org.mobilenativefoundation.store6.mutations.storage/MutationExecutionRecord) // org.mobilenativefoundation.store6.mutations.storage/MutationJournalTransaction.advanceExecution|advanceExecution(org.mobilenativefoundation.store6.mutations.storage.MutationExecutionRecord){}[0] + abstract fun advanceTombstone(org.mobilenativefoundation.store6.mutations.storage/MutationKeyTombstoneRecord) // org.mobilenativefoundation.store6.mutations.storage/MutationJournalTransaction.advanceTombstone|advanceTombstone(org.mobilenativefoundation.store6.mutations.storage.MutationKeyTombstoneRecord){}[0] + abstract fun aliases(): kotlin.collections/List // org.mobilenativefoundation.store6.mutations.storage/MutationJournalTransaction.aliases|aliases(){}[0] + abstract fun appendFailure(kotlin/String, kotlin/Long, kotlin/Int, org.mobilenativefoundation.store6.mutations/MutationFailureKind, kotlin/String, kotlin/String, kotlin/Long): org.mobilenativefoundation.store6.mutations.storage/MutationFailureRecord // org.mobilenativefoundation.store6.mutations.storage/MutationJournalTransaction.appendFailure|appendFailure(kotlin.String;kotlin.Long;kotlin.Int;org.mobilenativefoundation.store6.mutations.MutationFailureKind;kotlin.String;kotlin.String;kotlin.Long){}[0] + abstract fun attempts(kotlin/String): kotlin.collections/List // org.mobilenativefoundation.store6.mutations.storage/MutationJournalTransaction.attempts|attempts(kotlin.String){}[0] + abstract fun client(kotlin/String): org.mobilenativefoundation.store6.mutations.storage/MutationClientRecord? // org.mobilenativefoundation.store6.mutations.storage/MutationJournalTransaction.client|client(kotlin.String){}[0] + abstract fun confirmRetiredThrough(kotlin/String, kotlin/Long, kotlin/Long): org.mobilenativefoundation.store6.mutations.storage/MutationClientRecord // org.mobilenativefoundation.store6.mutations.storage/MutationJournalTransaction.confirmRetiredThrough|confirmRetiredThrough(kotlin.String;kotlin.Long;kotlin.Long){}[0] + abstract fun effects(kotlin/String): kotlin.collections/List // org.mobilenativefoundation.store6.mutations.storage/MutationJournalTransaction.effects|effects(kotlin.String){}[0] + abstract fun executions(kotlin/String): kotlin.collections/List // org.mobilenativefoundation.store6.mutations.storage/MutationJournalTransaction.executions|executions(kotlin.String){}[0] + abstract fun failures(kotlin/String): kotlin.collections/List // org.mobilenativefoundation.store6.mutations.storage/MutationJournalTransaction.failures|failures(kotlin.String){}[0] + abstract fun insertAck(org.mobilenativefoundation.store6.mutations.storage/MutationAckRecord) // org.mobilenativefoundation.store6.mutations.storage/MutationJournalTransaction.insertAck|insertAck(org.mobilenativefoundation.store6.mutations.storage.MutationAckRecord){}[0] + abstract fun insertAlias(org.mobilenativefoundation.store6.mutations.storage/MutationKeyAliasRecord) // org.mobilenativefoundation.store6.mutations.storage/MutationJournalTransaction.insertAlias|insertAlias(org.mobilenativefoundation.store6.mutations.storage.MutationKeyAliasRecord){}[0] + abstract fun insertAttempt(org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord) // org.mobilenativefoundation.store6.mutations.storage/MutationJournalTransaction.insertAttempt|insertAttempt(org.mobilenativefoundation.store6.mutations.storage.MutationAttemptRecord){}[0] + abstract fun insertClient(org.mobilenativefoundation.store6.mutations.storage/MutationClientRecord) // org.mobilenativefoundation.store6.mutations.storage/MutationJournalTransaction.insertClient|insertClient(org.mobilenativefoundation.store6.mutations.storage.MutationClientRecord){}[0] + abstract fun insertEffect(org.mobilenativefoundation.store6.mutations.storage/MutationEffectRecord) // org.mobilenativefoundation.store6.mutations.storage/MutationJournalTransaction.insertEffect|insertEffect(org.mobilenativefoundation.store6.mutations.storage.MutationEffectRecord){}[0] + abstract fun insertExecution(org.mobilenativefoundation.store6.mutations.storage/MutationExecutionRecord) // org.mobilenativefoundation.store6.mutations.storage/MutationJournalTransaction.insertExecution|insertExecution(org.mobilenativefoundation.store6.mutations.storage.MutationExecutionRecord){}[0] + abstract fun insertIntent(kotlin/Int, kotlin/String, kotlin/Long, kotlin/String, kotlin/String, kotlin/String, kotlin/String, kotlin/Int, kotlin/ByteArray, kotlin/String, kotlin/Long): org.mobilenativefoundation.store6.mutations.storage/MutationIntentRecord // org.mobilenativefoundation.store6.mutations.storage/MutationJournalTransaction.insertIntent|insertIntent(kotlin.Int;kotlin.String;kotlin.Long;kotlin.String;kotlin.String;kotlin.String;kotlin.String;kotlin.Int;kotlin.ByteArray;kotlin.String;kotlin.Long){}[0] + abstract fun insertTombstone(org.mobilenativefoundation.store6.mutations.storage/MutationKeyTombstoneRecord) // org.mobilenativefoundation.store6.mutations.storage/MutationJournalTransaction.insertTombstone|insertTombstone(org.mobilenativefoundation.store6.mutations.storage.MutationKeyTombstoneRecord){}[0] + abstract fun intents(kotlin/String): kotlin.collections/List // org.mobilenativefoundation.store6.mutations.storage/MutationJournalTransaction.intents|intents(kotlin.String){}[0] + abstract fun prune(kotlin/String, kotlin/Long) // org.mobilenativefoundation.store6.mutations.storage/MutationJournalTransaction.prune|prune(kotlin.String;kotlin.Long){}[0] + abstract fun recordConflictReceipt(org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord) // org.mobilenativefoundation.store6.mutations.storage/MutationJournalTransaction.recordConflictReceipt|recordConflictReceipt(org.mobilenativefoundation.store6.mutations.storage.MutationAttemptRecord){}[0] + abstract fun tombstones(): kotlin.collections/List // org.mobilenativefoundation.store6.mutations.storage/MutationJournalTransaction.tombstones|tombstones(){}[0] +} + +sealed interface <#A: out kotlin/Any> org.mobilenativefoundation.store6.mutations/MutationConflictResolution { // org.mobilenativefoundation.store6.mutations/MutationConflictResolution|null[0] + final class <#A1: kotlin/Any> Retry : org.mobilenativefoundation.store6.mutations/MutationConflictResolution<#A1> { // org.mobilenativefoundation.store6.mutations/MutationConflictResolution.Retry|null[0] + constructor (org.mobilenativefoundation.store6.mutations/MutationPresence<#A1>) // org.mobilenativefoundation.store6.mutations/MutationConflictResolution.Retry.|(org.mobilenativefoundation.store6.mutations.MutationPresence<1:0>){}[0] + + final val value // org.mobilenativefoundation.store6.mutations/MutationConflictResolution.Retry.value|{}value[0] + final fun (): org.mobilenativefoundation.store6.mutations/MutationPresence<#A1> // org.mobilenativefoundation.store6.mutations/MutationConflictResolution.Retry.value.|(){}[0] + } + + final object ServerWins : org.mobilenativefoundation.store6.mutations/MutationConflictResolution // org.mobilenativefoundation.store6.mutations/MutationConflictResolution.ServerWins|null[0] +} + +sealed interface <#A: out kotlin/Any> org.mobilenativefoundation.store6.mutations/MutationPresence { // org.mobilenativefoundation.store6.mutations/MutationPresence|null[0] + final class <#A1: kotlin/Any> Present : org.mobilenativefoundation.store6.mutations/MutationPresence<#A1> { // org.mobilenativefoundation.store6.mutations/MutationPresence.Present|null[0] + constructor (#A1) // org.mobilenativefoundation.store6.mutations/MutationPresence.Present.|(1:0){}[0] + + final val value // org.mobilenativefoundation.store6.mutations/MutationPresence.Present.value|{}value[0] + final fun (): #A1 // org.mobilenativefoundation.store6.mutations/MutationPresence.Present.value.|(){}[0] + } + + final object Absent : org.mobilenativefoundation.store6.mutations/MutationPresence // org.mobilenativefoundation.store6.mutations/MutationPresence.Absent|null[0] +} + +sealed interface <#A: out org.mobilenativefoundation.store6.core/StoreKey, #B: out kotlin/Any> org.mobilenativefoundation.store6.mutations/MutationAck { // org.mobilenativefoundation.store6.mutations/MutationAck|null[0] + abstract val etag // org.mobilenativefoundation.store6.mutations/MutationAck.etag|{}etag[0] + abstract fun (): kotlin/String? // org.mobilenativefoundation.store6.mutations/MutationAck.etag.|(){}[0] +} + +sealed interface org.mobilenativefoundation.store6.mutations/MutationEvent { // org.mobilenativefoundation.store6.mutations/MutationEvent|null[0] + abstract val occurredAtEpochMillis // org.mobilenativefoundation.store6.mutations/MutationEvent.occurredAtEpochMillis|{}occurredAtEpochMillis[0] + abstract fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations/MutationEvent.occurredAtEpochMillis.|(){}[0] +} + +sealed interface org.mobilenativefoundation.store6.mutations/MutationIntentEvent : org.mobilenativefoundation.store6.mutations/MutationEvent { // org.mobilenativefoundation.store6.mutations/MutationIntentEvent|null[0] + abstract val identity // org.mobilenativefoundation.store6.mutations/MutationIntentEvent.identity|{}identity[0] + abstract fun (): org.mobilenativefoundation.store6.mutations/MutationKeyIdentity // org.mobilenativefoundation.store6.mutations/MutationIntentEvent.identity.|(){}[0] + abstract val mutationId // org.mobilenativefoundation.store6.mutations/MutationIntentEvent.mutationId|{}mutationId[0] + abstract fun (): kotlin/String // org.mobilenativefoundation.store6.mutations/MutationIntentEvent.mutationId.|(){}[0] +} + +final class <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any, #C: kotlin/Any> org.mobilenativefoundation.store6.mutations/MutatorRef { // org.mobilenativefoundation.store6.mutations/MutatorRef|null[0] + final val id // org.mobilenativefoundation.store6.mutations/MutatorRef.id|{}id[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations/MutatorRef.id.|(){}[0] +} + +final class <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> org.mobilenativefoundation.store6.mutations/MutationAbsentAck : org.mobilenativefoundation.store6.mutations/MutationAck<#A, #B> { // org.mobilenativefoundation.store6.mutations/MutationAbsentAck|null[0] + constructor (kotlin/String?) // org.mobilenativefoundation.store6.mutations/MutationAbsentAck.|(kotlin.String?){}[0] + + final val etag // org.mobilenativefoundation.store6.mutations/MutationAbsentAck.etag|{}etag[0] + final fun (): kotlin/String? // org.mobilenativefoundation.store6.mutations/MutationAbsentAck.etag.|(){}[0] +} + +final class <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> org.mobilenativefoundation.store6.mutations/MutationConflictBuilder { // org.mobilenativefoundation.store6.mutations/MutationConflictBuilder|null[0] + final fun merge(kotlin/Function3, org.mobilenativefoundation.store6.mutations/MutationPresence<#B>, org.mobilenativefoundation.store6.mutations/MutationPresence<#B>, org.mobilenativefoundation.store6.mutations/MutationConflictResolution<#B>>) // org.mobilenativefoundation.store6.mutations/MutationConflictBuilder.merge|merge(kotlin.Function3,org.mobilenativefoundation.store6.mutations.MutationPresence<1:1>,org.mobilenativefoundation.store6.mutations.MutationPresence<1:1>,org.mobilenativefoundation.store6.mutations.MutationConflictResolution<1:1>>){}[0] + final fun precondition(kotlin/Function1, org.mobilenativefoundation.store6.core/StoreMeta?>) // org.mobilenativefoundation.store6.mutations/MutationConflictBuilder.precondition|precondition(kotlin.Function1,org.mobilenativefoundation.store6.core.StoreMeta?>){}[0] +} + +final class <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> org.mobilenativefoundation.store6.mutations/MutationPreconditionCandidate { // org.mobilenativefoundation.store6.mutations/MutationPreconditionCandidate|null[0] + final val base // org.mobilenativefoundation.store6.mutations/MutationPreconditionCandidate.base|{}base[0] + final fun (): org.mobilenativefoundation.store6.mutations/MutationPresence<#B> // org.mobilenativefoundation.store6.mutations/MutationPreconditionCandidate.base.|(){}[0] + final val capturedMeta // org.mobilenativefoundation.store6.mutations/MutationPreconditionCandidate.capturedMeta|{}capturedMeta[0] + final fun (): org.mobilenativefoundation.store6.core/StoreMeta? // org.mobilenativefoundation.store6.mutations/MutationPreconditionCandidate.capturedMeta.|(){}[0] + final val generation // org.mobilenativefoundation.store6.mutations/MutationPreconditionCandidate.generation|{}generation[0] + final fun (): kotlin/Int // org.mobilenativefoundation.store6.mutations/MutationPreconditionCandidate.generation.|(){}[0] + final val identity // org.mobilenativefoundation.store6.mutations/MutationPreconditionCandidate.identity|{}identity[0] + final fun (): org.mobilenativefoundation.store6.mutations/MutationKeyIdentity // org.mobilenativefoundation.store6.mutations/MutationPreconditionCandidate.identity.|(){}[0] + final val key // org.mobilenativefoundation.store6.mutations/MutationPreconditionCandidate.key|{}key[0] + final fun (): #A // org.mobilenativefoundation.store6.mutations/MutationPreconditionCandidate.key.|(){}[0] + final val mine // org.mobilenativefoundation.store6.mutations/MutationPreconditionCandidate.mine|{}mine[0] + final fun (): org.mobilenativefoundation.store6.mutations/MutationPresence<#B> // org.mobilenativefoundation.store6.mutations/MutationPreconditionCandidate.mine.|(){}[0] + final val mutationId // org.mobilenativefoundation.store6.mutations/MutationPreconditionCandidate.mutationId|{}mutationId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations/MutationPreconditionCandidate.mutationId.|(){}[0] +} + +final class <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> org.mobilenativefoundation.store6.mutations/MutationPresentAck : org.mobilenativefoundation.store6.mutations/MutationAck<#A, #B> { // org.mobilenativefoundation.store6.mutations/MutationPresentAck|null[0] + constructor (#B, kotlin/String?, #A?) // org.mobilenativefoundation.store6.mutations/MutationPresentAck.|(1:1;kotlin.String?;1:0?){}[0] + + final val authoritative // org.mobilenativefoundation.store6.mutations/MutationPresentAck.authoritative|{}authoritative[0] + final fun (): #B // org.mobilenativefoundation.store6.mutations/MutationPresentAck.authoritative.|(){}[0] + final val canonicalKey // org.mobilenativefoundation.store6.mutations/MutationPresentAck.canonicalKey|{}canonicalKey[0] + final fun (): #A? // org.mobilenativefoundation.store6.mutations/MutationPresentAck.canonicalKey.|(){}[0] + final val etag // org.mobilenativefoundation.store6.mutations/MutationPresentAck.etag|{}etag[0] + final fun (): kotlin/String? // org.mobilenativefoundation.store6.mutations/MutationPresentAck.etag.|(){}[0] +} + +final class <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> org.mobilenativefoundation.store6.mutations/MutationPush { // org.mobilenativefoundation.store6.mutations/MutationPush|null[0] + final val base // org.mobilenativefoundation.store6.mutations/MutationPush.base|{}base[0] + final fun (): org.mobilenativefoundation.store6.mutations/MutationPresence<#B> // org.mobilenativefoundation.store6.mutations/MutationPush.base.|(){}[0] + final val baseMeta // org.mobilenativefoundation.store6.mutations/MutationPush.baseMeta|{}baseMeta[0] + final fun (): org.mobilenativefoundation.store6.core/StoreMeta? // org.mobilenativefoundation.store6.mutations/MutationPush.baseMeta.|(){}[0] + final val clientId // org.mobilenativefoundation.store6.mutations/MutationPush.clientId|{}clientId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations/MutationPush.clientId.|(){}[0] + final val clientSequence // org.mobilenativefoundation.store6.mutations/MutationPush.clientSequence|{}clientSequence[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations/MutationPush.clientSequence.|(){}[0] + final val generation // org.mobilenativefoundation.store6.mutations/MutationPush.generation|{}generation[0] + final fun (): kotlin/Int // org.mobilenativefoundation.store6.mutations/MutationPush.generation.|(){}[0] + final val idempotencyKey // org.mobilenativefoundation.store6.mutations/MutationPush.idempotencyKey|{}idempotencyKey[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations/MutationPush.idempotencyKey.|(){}[0] + final val identity // org.mobilenativefoundation.store6.mutations/MutationPush.identity|{}identity[0] + final fun (): org.mobilenativefoundation.store6.mutations/MutationKeyIdentity // org.mobilenativefoundation.store6.mutations/MutationPush.identity.|(){}[0] + final val key // org.mobilenativefoundation.store6.mutations/MutationPush.key|{}key[0] + final fun (): #A // org.mobilenativefoundation.store6.mutations/MutationPush.key.|(){}[0] + final val mine // org.mobilenativefoundation.store6.mutations/MutationPush.mine|{}mine[0] + final fun (): org.mobilenativefoundation.store6.mutations/MutationPresence<#B> // org.mobilenativefoundation.store6.mutations/MutationPush.mine.|(){}[0] + final val mutationId // org.mobilenativefoundation.store6.mutations/MutationPush.mutationId|{}mutationId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations/MutationPush.mutationId.|(){}[0] + final val retiredThroughSequence // org.mobilenativefoundation.store6.mutations/MutationPush.retiredThroughSequence|{}retiredThroughSequence[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations/MutationPush.retiredThroughSequence.|(){}[0] + final val valueCodecVersion // org.mobilenativefoundation.store6.mutations/MutationPush.valueCodecVersion|{}valueCodecVersion[0] + final fun (): kotlin/Int // org.mobilenativefoundation.store6.mutations/MutationPush.valueCodecVersion.|(){}[0] +} + +final class <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> org.mobilenativefoundation.store6.mutations/MutationStore : org.mobilenativefoundation.store6.core/Store<#A, #B> { // org.mobilenativefoundation.store6.mutations/MutationStore|null[0] + final val events // org.mobilenativefoundation.store6.mutations/MutationStore.events|{}events[0] + final fun (): kotlinx.coroutines.flow/SharedFlow // org.mobilenativefoundation.store6.mutations/MutationStore.events.|(){}[0] + final val keyEvents // org.mobilenativefoundation.store6.mutations/MutationStore.keyEvents|{}keyEvents[0] + final fun (): kotlinx.coroutines.flow/Flow // org.mobilenativefoundation.store6.mutations/MutationStore.keyEvents.|(){}[0] + final val poisoned // org.mobilenativefoundation.store6.mutations/MutationStore.poisoned|{}poisoned[0] + final fun (): kotlinx.coroutines.flow/SharedFlow // org.mobilenativefoundation.store6.mutations/MutationStore.poisoned.|(){}[0] + + final fun close() // org.mobilenativefoundation.store6.mutations/MutationStore.close|close(){}[0] + final fun stream(#A, org.mobilenativefoundation.store6.core/Freshness): kotlinx.coroutines.flow/Flow> // org.mobilenativefoundation.store6.mutations/MutationStore.stream|stream(1:0;org.mobilenativefoundation.store6.core.Freshness){}[0] + final suspend fun <#A1: kotlin/Any> mutate(#A, org.mobilenativefoundation.store6.mutations/MutatorRef<#A, #B, #A1>, #A1): kotlin/String // org.mobilenativefoundation.store6.mutations/MutationStore.mutate|mutate(1:0;org.mobilenativefoundation.store6.mutations.MutatorRef<1:0,1:1,0:0>;0:0){0§}[0] + final suspend fun clear(#A) // org.mobilenativefoundation.store6.mutations/MutationStore.clear|clear(1:0){}[0] + final suspend fun clearAll() // org.mobilenativefoundation.store6.mutations/MutationStore.clearAll|clearAll(){}[0] + final suspend fun clearNamespace(org.mobilenativefoundation.store6.core/StoreNamespace) // org.mobilenativefoundation.store6.mutations/MutationStore.clearNamespace|clearNamespace(org.mobilenativefoundation.store6.core.StoreNamespace){}[0] + final suspend fun deadLetters(): kotlin.collections/List // org.mobilenativefoundation.store6.mutations/MutationStore.deadLetters|deadLetters(){}[0] + final suspend fun drain(#A) // org.mobilenativefoundation.store6.mutations/MutationStore.drain|drain(1:0){}[0] + final suspend fun drain() // org.mobilenativefoundation.store6.mutations/MutationStore.drain|drain(){}[0] + final suspend fun get(#A, org.mobilenativefoundation.store6.core/Freshness): #B // org.mobilenativefoundation.store6.mutations/MutationStore.get|get(1:0;org.mobilenativefoundation.store6.core.Freshness){}[0] + final suspend fun invalidate(#A) // org.mobilenativefoundation.store6.mutations/MutationStore.invalidate|invalidate(1:0){}[0] + final suspend fun invalidateAll() // org.mobilenativefoundation.store6.mutations/MutationStore.invalidateAll|invalidateAll(){}[0] + final suspend fun invalidateNamespace(org.mobilenativefoundation.store6.core/StoreNamespace) // org.mobilenativefoundation.store6.mutations/MutationStore.invalidateNamespace|invalidateNamespace(org.mobilenativefoundation.store6.core.StoreNamespace){}[0] + final suspend fun pending(#A): kotlin.collections/List // org.mobilenativefoundation.store6.mutations/MutationStore.pending|pending(1:0){}[0] + final suspend fun pendingWrites(): kotlin.collections/List // org.mobilenativefoundation.store6.mutations/MutationStore.pendingWrites|pendingWrites(){}[0] +} + +final class <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> org.mobilenativefoundation.store6.mutations/MutationStoreBuilder { // org.mobilenativefoundation.store6.mutations/MutationStoreBuilder|null[0] + final fun bookkeeper(org.mobilenativefoundation.store6.core.seam/Bookkeeper) // org.mobilenativefoundation.store6.mutations/MutationStoreBuilder.bookkeeper|bookkeeper(org.mobilenativefoundation.store6.core.seam.Bookkeeper){}[0] + final fun conflicts(kotlin/Function1, kotlin/Unit>) // org.mobilenativefoundation.store6.mutations/MutationStoreBuilder.conflicts|conflicts(kotlin.Function1,kotlin.Unit>){}[0] + final fun fetcher(kotlin.coroutines/SuspendFunction1<#A, #B>) // org.mobilenativefoundation.store6.mutations/MutationStoreBuilder.fetcher|fetcher(kotlin.coroutines.SuspendFunction1<1:0,1:1>){}[0] + final fun fetcher(org.mobilenativefoundation.store6.core.seam/Fetcher<#A, #B>) // org.mobilenativefoundation.store6.mutations/MutationStoreBuilder.fetcher|fetcher(org.mobilenativefoundation.store6.core.seam.Fetcher<1:0,1:1>){}[0] + final fun fetcherOfResult(kotlin.coroutines/SuspendFunction1<#A, org.mobilenativefoundation.store6.core.seam/FetcherResult<#B>>) // org.mobilenativefoundation.store6.mutations/MutationStoreBuilder.fetcherOfResult|fetcherOfResult(kotlin.coroutines.SuspendFunction1<1:0,org.mobilenativefoundation.store6.core.seam.FetcherResult<1:1>>){}[0] + final fun freshnessValidator(org.mobilenativefoundation.store6.core.seam/FreshnessValidator) // org.mobilenativefoundation.store6.mutations/MutationStoreBuilder.freshnessValidator|freshnessValidator(org.mobilenativefoundation.store6.core.seam.FreshnessValidator){}[0] + final fun journalStorage(org.mobilenativefoundation.store6.mutations.storage/MutationJournalStorage) // org.mobilenativefoundation.store6.mutations/MutationStoreBuilder.journalStorage|journalStorage(org.mobilenativefoundation.store6.mutations.storage.MutationJournalStorage){}[0] + final fun maxIdleKeys(kotlin/Int) // org.mobilenativefoundation.store6.mutations/MutationStoreBuilder.maxIdleKeys|maxIdleKeys(kotlin.Int){}[0] + final fun persistence(org.mobilenativefoundation.store6.core.seam/SourceOfTruth<#A, #B>) // org.mobilenativefoundation.store6.mutations/MutationStoreBuilder.persistence|persistence(org.mobilenativefoundation.store6.core.seam.SourceOfTruth<1:0,1:1>){}[0] + final fun telemetry(org.mobilenativefoundation.store6.core.seam/StoreTelemetry) // org.mobilenativefoundation.store6.mutations/MutationStoreBuilder.telemetry|telemetry(org.mobilenativefoundation.store6.core.seam.StoreTelemetry){}[0] + final fun wallClock(org.mobilenativefoundation.store6.core.seam/WallClock) // org.mobilenativefoundation.store6.mutations/MutationStoreBuilder.wallClock|wallClock(org.mobilenativefoundation.store6.core.seam.WallClock){}[0] +} + +final class <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> org.mobilenativefoundation.store6.mutations/MutatorRegistry // org.mobilenativefoundation.store6.mutations/MutatorRegistry|null[0] + +final class <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> org.mobilenativefoundation.store6.mutations/MutatorRegistryBuilder { // org.mobilenativefoundation.store6.mutations/MutatorRegistryBuilder|null[0] + final fun <#A1: kotlin/Any> create(kotlin/String, kotlin/Int, org.mobilenativefoundation.store6.mutations/MutationCodec<#A1>, kotlin/Function2<#A, #A1, org.mobilenativefoundation.store6.mutations/StaleSet<#A>>, kotlin/Function1<#A1, #B>): org.mobilenativefoundation.store6.mutations/MutatorRef<#A, #B, #A1> // org.mobilenativefoundation.store6.mutations/MutatorRegistryBuilder.create|create(kotlin.String;kotlin.Int;org.mobilenativefoundation.store6.mutations.MutationCodec<0:0>;kotlin.Function2<1:0,0:0,org.mobilenativefoundation.store6.mutations.StaleSet<1:0>>;kotlin.Function1<0:0,1:1>){0§}[0] + final fun <#A1: kotlin/Any> mutator(kotlin/String, kotlin/Int, org.mobilenativefoundation.store6.mutations/MutationCodec<#A1>, kotlin/Function2<#A, #A1, org.mobilenativefoundation.store6.mutations/StaleSet<#A>>, kotlin/Function2, #A1, org.mobilenativefoundation.store6.mutations/MutationPresence<#B>?>): org.mobilenativefoundation.store6.mutations/MutatorRef<#A, #B, #A1> // org.mobilenativefoundation.store6.mutations/MutatorRegistryBuilder.mutator|mutator(kotlin.String;kotlin.Int;org.mobilenativefoundation.store6.mutations.MutationCodec<0:0>;kotlin.Function2<1:0,0:0,org.mobilenativefoundation.store6.mutations.StaleSet<1:0>>;kotlin.Function2,0:0,org.mobilenativefoundation.store6.mutations.MutationPresence<1:1>?>){0§}[0] + final fun <#A1: kotlin/Any> update(kotlin/String, kotlin/Int, org.mobilenativefoundation.store6.mutations/MutationCodec<#A1>, kotlin/Function2<#A, #A1, org.mobilenativefoundation.store6.mutations/StaleSet<#A>>, kotlin/Function2<#B, #A1, #B>): org.mobilenativefoundation.store6.mutations/MutatorRef<#A, #B, #A1> // org.mobilenativefoundation.store6.mutations/MutatorRegistryBuilder.update|update(kotlin.String;kotlin.Int;org.mobilenativefoundation.store6.mutations.MutationCodec<0:0>;kotlin.Function2<1:0,0:0,org.mobilenativefoundation.store6.mutations.StaleSet<1:0>>;kotlin.Function2<1:1,0:0,1:1>){0§}[0] + final fun <#A1: kotlin/Any> upsert(kotlin/String, kotlin/Int, org.mobilenativefoundation.store6.mutations/MutationCodec<#A1>, kotlin/Function2<#A, #A1, org.mobilenativefoundation.store6.mutations/StaleSet<#A>>, kotlin/Function2, #A1, org.mobilenativefoundation.store6.mutations/MutationPresence<#B>>): org.mobilenativefoundation.store6.mutations/MutatorRef<#A, #B, #A1> // org.mobilenativefoundation.store6.mutations/MutatorRegistryBuilder.upsert|upsert(kotlin.String;kotlin.Int;org.mobilenativefoundation.store6.mutations.MutationCodec<0:0>;kotlin.Function2<1:0,0:0,org.mobilenativefoundation.store6.mutations.StaleSet<1:0>>;kotlin.Function2,0:0,org.mobilenativefoundation.store6.mutations.MutationPresence<1:1>>){0§}[0] + final fun delete(kotlin/String, kotlin/Function2<#A, kotlin/Unit, org.mobilenativefoundation.store6.mutations/StaleSet<#A>>): org.mobilenativefoundation.store6.mutations/MutatorRef<#A, #B, kotlin/Unit> // org.mobilenativefoundation.store6.mutations/MutatorRegistryBuilder.delete|delete(kotlin.String;kotlin.Function2<1:0,kotlin.Unit,org.mobilenativefoundation.store6.mutations.StaleSet<1:0>>){}[0] +} + +final class <#A: org.mobilenativefoundation.store6.core/StoreKey> org.mobilenativefoundation.store6.mutations/StaleSet { // org.mobilenativefoundation.store6.mutations/StaleSet|null[0] + constructor (kotlin.collections/Set<#A>, kotlin.collections/Set) // org.mobilenativefoundation.store6.mutations/StaleSet.|(kotlin.collections.Set<1:0>;kotlin.collections.Set){}[0] + + final val keys // org.mobilenativefoundation.store6.mutations/StaleSet.keys|{}keys[0] + final fun (): kotlin.collections/Set<#A> // org.mobilenativefoundation.store6.mutations/StaleSet.keys.|(){}[0] + final val namespaces // org.mobilenativefoundation.store6.mutations/StaleSet.namespaces|{}namespaces[0] + final fun (): kotlin.collections/Set // org.mobilenativefoundation.store6.mutations/StaleSet.namespaces.|(){}[0] +} + +final class org.mobilenativefoundation.store6.mutations.storage/InMemoryMutationJournalStorage : org.mobilenativefoundation.store6.mutations.storage/MutationJournalStorage { // org.mobilenativefoundation.store6.mutations.storage/InMemoryMutationJournalStorage|null[0] + constructor () // org.mobilenativefoundation.store6.mutations.storage/InMemoryMutationJournalStorage.|(){}[0] + + final suspend fun <#A1: kotlin/Any?> transaction(kotlin/Function1): #A1 // org.mobilenativefoundation.store6.mutations.storage/InMemoryMutationJournalStorage.transaction|transaction(kotlin.Function1){0§}[0] +} + +final class org.mobilenativefoundation.store6.mutations.storage/MutationAckRecord { // org.mobilenativefoundation.store6.mutations.storage/MutationAckRecord|null[0] + constructor (kotlin/String, kotlin/Long, kotlin/Int, org.mobilenativefoundation.store6.mutations/MutationPresenceState, kotlin/ByteArray?, kotlin/Int, kotlin/String?, kotlin/String?, kotlin/String?, kotlin/Long) // org.mobilenativefoundation.store6.mutations.storage/MutationAckRecord.|(kotlin.String;kotlin.Long;kotlin.Int;org.mobilenativefoundation.store6.mutations.MutationPresenceState;kotlin.ByteArray?;kotlin.Int;kotlin.String?;kotlin.String?;kotlin.String?;kotlin.Long){}[0] + + final val authoritativeBlob // org.mobilenativefoundation.store6.mutations.storage/MutationAckRecord.authoritativeBlob|{}authoritativeBlob[0] + final fun (): kotlin/ByteArray? // org.mobilenativefoundation.store6.mutations.storage/MutationAckRecord.authoritativeBlob.|(){}[0] + final val authoritativePresence // org.mobilenativefoundation.store6.mutations.storage/MutationAckRecord.authoritativePresence|{}authoritativePresence[0] + final fun (): org.mobilenativefoundation.store6.mutations/MutationPresenceState // org.mobilenativefoundation.store6.mutations.storage/MutationAckRecord.authoritativePresence.|(){}[0] + final val canonicalTargetId // org.mobilenativefoundation.store6.mutations.storage/MutationAckRecord.canonicalTargetId|{}canonicalTargetId[0] + final fun (): kotlin/String? // org.mobilenativefoundation.store6.mutations.storage/MutationAckRecord.canonicalTargetId.|(){}[0] + final val canonicalTargetNamespace // org.mobilenativefoundation.store6.mutations.storage/MutationAckRecord.canonicalTargetNamespace|{}canonicalTargetNamespace[0] + final fun (): kotlin/String? // org.mobilenativefoundation.store6.mutations.storage/MutationAckRecord.canonicalTargetNamespace.|(){}[0] + final val clientId // org.mobilenativefoundation.store6.mutations.storage/MutationAckRecord.clientId|{}clientId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations.storage/MutationAckRecord.clientId.|(){}[0] + final val clientSequence // org.mobilenativefoundation.store6.mutations.storage/MutationAckRecord.clientSequence|{}clientSequence[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations.storage/MutationAckRecord.clientSequence.|(){}[0] + final val etag // org.mobilenativefoundation.store6.mutations.storage/MutationAckRecord.etag|{}etag[0] + final fun (): kotlin/String? // org.mobilenativefoundation.store6.mutations.storage/MutationAckRecord.etag.|(){}[0] + final val generation // org.mobilenativefoundation.store6.mutations.storage/MutationAckRecord.generation|{}generation[0] + final fun (): kotlin/Int // org.mobilenativefoundation.store6.mutations.storage/MutationAckRecord.generation.|(){}[0] + final val receivedAt // org.mobilenativefoundation.store6.mutations.storage/MutationAckRecord.receivedAt|{}receivedAt[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations.storage/MutationAckRecord.receivedAt.|(){}[0] + final val valueCodecVersion // org.mobilenativefoundation.store6.mutations.storage/MutationAckRecord.valueCodecVersion|{}valueCodecVersion[0] + final fun (): kotlin/Int // org.mobilenativefoundation.store6.mutations.storage/MutationAckRecord.valueCodecVersion.|(){}[0] +} + +final class org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord { // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord|null[0] + constructor (kotlin/String, kotlin/Long, kotlin/Int, kotlin/String, kotlin/String, kotlin/Int, org.mobilenativefoundation.store6.mutations/MutationPresenceState, kotlin/ByteArray?, org.mobilenativefoundation.store6.mutations/MutationPresenceState, kotlin/ByteArray?, kotlin/Boolean, kotlin/Long?, kotlin/String?, kotlin/Long, kotlin/String, kotlin/Long, kotlin/Boolean?, kotlin/Long?, kotlin/String?, kotlin/Long?) // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.|(kotlin.String;kotlin.Long;kotlin.Int;kotlin.String;kotlin.String;kotlin.Int;org.mobilenativefoundation.store6.mutations.MutationPresenceState;kotlin.ByteArray?;org.mobilenativefoundation.store6.mutations.MutationPresenceState;kotlin.ByteArray?;kotlin.Boolean;kotlin.Long?;kotlin.String?;kotlin.Long;kotlin.String;kotlin.Long;kotlin.Boolean?;kotlin.Long?;kotlin.String?;kotlin.Long?){}[0] + + final val advertisedRetiredThroughSequence // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.advertisedRetiredThroughSequence|{}advertisedRetiredThroughSequence[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.advertisedRetiredThroughSequence.|(){}[0] + final val baseBlob // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.baseBlob|{}baseBlob[0] + final fun (): kotlin/ByteArray? // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.baseBlob.|(){}[0] + final val basePresence // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.basePresence|{}basePresence[0] + final fun (): org.mobilenativefoundation.store6.mutations/MutationPresenceState // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.basePresence.|(){}[0] + final val clientId // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.clientId|{}clientId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.clientId.|(){}[0] + final val clientSequence // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.clientSequence|{}clientSequence[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.clientSequence.|(){}[0] + final val conflictEtag // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.conflictEtag|{}conflictEtag[0] + final fun (): kotlin/String? // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.conflictEtag.|(){}[0] + final val conflictMetaPresent // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.conflictMetaPresent|{}conflictMetaPresent[0] + final fun (): kotlin/Boolean? // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.conflictMetaPresent.|(){}[0] + final val conflictReceivedAt // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.conflictReceivedAt|{}conflictReceivedAt[0] + final fun (): kotlin/Long? // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.conflictReceivedAt.|(){}[0] + final val conflictWrittenAt // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.conflictWrittenAt|{}conflictWrittenAt[0] + final fun (): kotlin/Long? // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.conflictWrittenAt.|(){}[0] + final val effectiveCanonicalId // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.effectiveCanonicalId|{}effectiveCanonicalId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.effectiveCanonicalId.|(){}[0] + final val effectiveNamespace // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.effectiveNamespace|{}effectiveNamespace[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.effectiveNamespace.|(){}[0] + final val generation // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.generation|{}generation[0] + final fun (): kotlin/Int // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.generation.|(){}[0] + final val generationIdempotencyKey // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.generationIdempotencyKey|{}generationIdempotencyKey[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.generationIdempotencyKey.|(){}[0] + final val mineBlob // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.mineBlob|{}mineBlob[0] + final fun (): kotlin/ByteArray? // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.mineBlob.|(){}[0] + final val minePresence // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.minePresence|{}minePresence[0] + final fun (): org.mobilenativefoundation.store6.mutations/MutationPresenceState // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.minePresence.|(){}[0] + final val preconditionEtag // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.preconditionEtag|{}preconditionEtag[0] + final fun (): kotlin/String? // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.preconditionEtag.|(){}[0] + final val preconditionMetaPresent // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.preconditionMetaPresent|{}preconditionMetaPresent[0] + final fun (): kotlin/Boolean // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.preconditionMetaPresent.|(){}[0] + final val preconditionWrittenAt // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.preconditionWrittenAt|{}preconditionWrittenAt[0] + final fun (): kotlin/Long? // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.preconditionWrittenAt.|(){}[0] + final val preparedAt // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.preparedAt|{}preparedAt[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.preparedAt.|(){}[0] + final val valueCodecVersion // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.valueCodecVersion|{}valueCodecVersion[0] + final fun (): kotlin/Int // org.mobilenativefoundation.store6.mutations.storage/MutationAttemptRecord.valueCodecVersion.|(){}[0] +} + +final class org.mobilenativefoundation.store6.mutations.storage/MutationClientRecord { // org.mobilenativefoundation.store6.mutations.storage/MutationClientRecord|null[0] + constructor (kotlin/Int, kotlin/String, kotlin/Long, kotlin/Long, kotlin/Long, kotlin/Long) // org.mobilenativefoundation.store6.mutations.storage/MutationClientRecord.|(kotlin.Int;kotlin.String;kotlin.Long;kotlin.Long;kotlin.Long;kotlin.Long){}[0] + + final val clientId // org.mobilenativefoundation.store6.mutations.storage/MutationClientRecord.clientId|{}clientId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations.storage/MutationClientRecord.clientId.|(){}[0] + final val createdAt // org.mobilenativefoundation.store6.mutations.storage/MutationClientRecord.createdAt|{}createdAt[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations.storage/MutationClientRecord.createdAt.|(){}[0] + final val lastAllocatedSequence // org.mobilenativefoundation.store6.mutations.storage/MutationClientRecord.lastAllocatedSequence|{}lastAllocatedSequence[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations.storage/MutationClientRecord.lastAllocatedSequence.|(){}[0] + final val recordVersion // org.mobilenativefoundation.store6.mutations.storage/MutationClientRecord.recordVersion|{}recordVersion[0] + final fun (): kotlin/Int // org.mobilenativefoundation.store6.mutations.storage/MutationClientRecord.recordVersion.|(){}[0] + final val retiredThroughSequence // org.mobilenativefoundation.store6.mutations.storage/MutationClientRecord.retiredThroughSequence|{}retiredThroughSequence[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations.storage/MutationClientRecord.retiredThroughSequence.|(){}[0] + final val serverConfirmedRetiredThroughSequence // org.mobilenativefoundation.store6.mutations.storage/MutationClientRecord.serverConfirmedRetiredThroughSequence|{}serverConfirmedRetiredThroughSequence[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations.storage/MutationClientRecord.serverConfirmedRetiredThroughSequence.|(){}[0] +} + +final class org.mobilenativefoundation.store6.mutations.storage/MutationEffectRecord { // org.mobilenativefoundation.store6.mutations.storage/MutationEffectRecord|null[0] + constructor (kotlin/String, kotlin/Long, kotlin/Int, org.mobilenativefoundation.store6.mutations.storage/MutationEffectKind, kotlin/String, kotlin/String?, kotlin/Long, org.mobilenativefoundation.store6.mutations.storage/MutationEffectDisposition, kotlin/Long?) // org.mobilenativefoundation.store6.mutations.storage/MutationEffectRecord.|(kotlin.String;kotlin.Long;kotlin.Int;org.mobilenativefoundation.store6.mutations.storage.MutationEffectKind;kotlin.String;kotlin.String?;kotlin.Long;org.mobilenativefoundation.store6.mutations.storage.MutationEffectDisposition;kotlin.Long?){}[0] + + final val canonicalId // org.mobilenativefoundation.store6.mutations.storage/MutationEffectRecord.canonicalId|{}canonicalId[0] + final fun (): kotlin/String? // org.mobilenativefoundation.store6.mutations.storage/MutationEffectRecord.canonicalId.|(){}[0] + final val clientId // org.mobilenativefoundation.store6.mutations.storage/MutationEffectRecord.clientId|{}clientId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations.storage/MutationEffectRecord.clientId.|(){}[0] + final val clientSequence // org.mobilenativefoundation.store6.mutations.storage/MutationEffectRecord.clientSequence|{}clientSequence[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations.storage/MutationEffectRecord.clientSequence.|(){}[0] + final val completedAt // org.mobilenativefoundation.store6.mutations.storage/MutationEffectRecord.completedAt|{}completedAt[0] + final fun (): kotlin/Long? // org.mobilenativefoundation.store6.mutations.storage/MutationEffectRecord.completedAt.|(){}[0] + final val createdAt // org.mobilenativefoundation.store6.mutations.storage/MutationEffectRecord.createdAt|{}createdAt[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations.storage/MutationEffectRecord.createdAt.|(){}[0] + final val disposition // org.mobilenativefoundation.store6.mutations.storage/MutationEffectRecord.disposition|{}disposition[0] + final fun (): org.mobilenativefoundation.store6.mutations.storage/MutationEffectDisposition // org.mobilenativefoundation.store6.mutations.storage/MutationEffectRecord.disposition.|(){}[0] + final val effectIndex // org.mobilenativefoundation.store6.mutations.storage/MutationEffectRecord.effectIndex|{}effectIndex[0] + final fun (): kotlin/Int // org.mobilenativefoundation.store6.mutations.storage/MutationEffectRecord.effectIndex.|(){}[0] + final val kind // org.mobilenativefoundation.store6.mutations.storage/MutationEffectRecord.kind|{}kind[0] + final fun (): org.mobilenativefoundation.store6.mutations.storage/MutationEffectKind // org.mobilenativefoundation.store6.mutations.storage/MutationEffectRecord.kind.|(){}[0] + final val namespace // org.mobilenativefoundation.store6.mutations.storage/MutationEffectRecord.namespace|{}namespace[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations.storage/MutationEffectRecord.namespace.|(){}[0] +} + +final class org.mobilenativefoundation.store6.mutations.storage/MutationExecutionRecord { // org.mobilenativefoundation.store6.mutations.storage/MutationExecutionRecord|null[0] + constructor (kotlin/String, kotlin/Long, org.mobilenativefoundation.store6.mutations.storage/MutationExecutionPhase, kotlin/Int, kotlin/Int, kotlin/Long?, kotlin/Long?, kotlin/Long?) // org.mobilenativefoundation.store6.mutations.storage/MutationExecutionRecord.|(kotlin.String;kotlin.Long;org.mobilenativefoundation.store6.mutations.storage.MutationExecutionPhase;kotlin.Int;kotlin.Int;kotlin.Long?;kotlin.Long?;kotlin.Long?){}[0] + + final val activeFailureId // org.mobilenativefoundation.store6.mutations.storage/MutationExecutionRecord.activeFailureId|{}activeFailureId[0] + final fun (): kotlin/Long? // org.mobilenativefoundation.store6.mutations.storage/MutationExecutionRecord.activeFailureId.|(){}[0] + final val attempt // org.mobilenativefoundation.store6.mutations.storage/MutationExecutionRecord.attempt|{}attempt[0] + final fun (): kotlin/Int // org.mobilenativefoundation.store6.mutations.storage/MutationExecutionRecord.attempt.|(){}[0] + final val clientId // org.mobilenativefoundation.store6.mutations.storage/MutationExecutionRecord.clientId|{}clientId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations.storage/MutationExecutionRecord.clientId.|(){}[0] + final val clientSequence // org.mobilenativefoundation.store6.mutations.storage/MutationExecutionRecord.clientSequence|{}clientSequence[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations.storage/MutationExecutionRecord.clientSequence.|(){}[0] + final val currentGeneration // org.mobilenativefoundation.store6.mutations.storage/MutationExecutionRecord.currentGeneration|{}currentGeneration[0] + final fun (): kotlin/Int // org.mobilenativefoundation.store6.mutations.storage/MutationExecutionRecord.currentGeneration.|(){}[0] + final val lastAttemptAt // org.mobilenativefoundation.store6.mutations.storage/MutationExecutionRecord.lastAttemptAt|{}lastAttemptAt[0] + final fun (): kotlin/Long? // org.mobilenativefoundation.store6.mutations.storage/MutationExecutionRecord.lastAttemptAt.|(){}[0] + final val phase // org.mobilenativefoundation.store6.mutations.storage/MutationExecutionRecord.phase|{}phase[0] + final fun (): org.mobilenativefoundation.store6.mutations.storage/MutationExecutionPhase // org.mobilenativefoundation.store6.mutations.storage/MutationExecutionRecord.phase.|(){}[0] + final val retiredAt // org.mobilenativefoundation.store6.mutations.storage/MutationExecutionRecord.retiredAt|{}retiredAt[0] + final fun (): kotlin/Long? // org.mobilenativefoundation.store6.mutations.storage/MutationExecutionRecord.retiredAt.|(){}[0] +} + +final class org.mobilenativefoundation.store6.mutations.storage/MutationFailureRecord { // org.mobilenativefoundation.store6.mutations.storage/MutationFailureRecord|null[0] + constructor (kotlin/Long, kotlin/String, kotlin/Long, kotlin/Int, org.mobilenativefoundation.store6.mutations/MutationFailureKind, kotlin/String, kotlin/String, kotlin/Long) // org.mobilenativefoundation.store6.mutations.storage/MutationFailureRecord.|(kotlin.Long;kotlin.String;kotlin.Long;kotlin.Int;org.mobilenativefoundation.store6.mutations.MutationFailureKind;kotlin.String;kotlin.String;kotlin.Long){}[0] + + final val clientId // org.mobilenativefoundation.store6.mutations.storage/MutationFailureRecord.clientId|{}clientId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations.storage/MutationFailureRecord.clientId.|(){}[0] + final val clientSequence // org.mobilenativefoundation.store6.mutations.storage/MutationFailureRecord.clientSequence|{}clientSequence[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations.storage/MutationFailureRecord.clientSequence.|(){}[0] + final val detail // org.mobilenativefoundation.store6.mutations.storage/MutationFailureRecord.detail|{}detail[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations.storage/MutationFailureRecord.detail.|(){}[0] + final val failureId // org.mobilenativefoundation.store6.mutations.storage/MutationFailureRecord.failureId|{}failureId[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations.storage/MutationFailureRecord.failureId.|(){}[0] + final val generation // org.mobilenativefoundation.store6.mutations.storage/MutationFailureRecord.generation|{}generation[0] + final fun (): kotlin/Int // org.mobilenativefoundation.store6.mutations.storage/MutationFailureRecord.generation.|(){}[0] + final val kind // org.mobilenativefoundation.store6.mutations.storage/MutationFailureRecord.kind|{}kind[0] + final fun (): org.mobilenativefoundation.store6.mutations/MutationFailureKind // org.mobilenativefoundation.store6.mutations.storage/MutationFailureRecord.kind.|(){}[0] + final val message // org.mobilenativefoundation.store6.mutations.storage/MutationFailureRecord.message|{}message[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations.storage/MutationFailureRecord.message.|(){}[0] + final val occurredAt // org.mobilenativefoundation.store6.mutations.storage/MutationFailureRecord.occurredAt|{}occurredAt[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations.storage/MutationFailureRecord.occurredAt.|(){}[0] +} + +final class org.mobilenativefoundation.store6.mutations.storage/MutationIntentRecord { // org.mobilenativefoundation.store6.mutations.storage/MutationIntentRecord|null[0] + constructor (kotlin/Long, kotlin/Int, kotlin/String, kotlin/Long, kotlin/String, kotlin/String, kotlin/String, kotlin/String, kotlin/Int, kotlin/ByteArray, kotlin/String, kotlin/Long) // org.mobilenativefoundation.store6.mutations.storage/MutationIntentRecord.|(kotlin.Long;kotlin.Int;kotlin.String;kotlin.Long;kotlin.String;kotlin.String;kotlin.String;kotlin.String;kotlin.Int;kotlin.ByteArray;kotlin.String;kotlin.Long){}[0] + + final val argsBlob // org.mobilenativefoundation.store6.mutations.storage/MutationIntentRecord.argsBlob|{}argsBlob[0] + final fun (): kotlin/ByteArray // org.mobilenativefoundation.store6.mutations.storage/MutationIntentRecord.argsBlob.|(){}[0] + final val canonicalId // org.mobilenativefoundation.store6.mutations.storage/MutationIntentRecord.canonicalId|{}canonicalId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations.storage/MutationIntentRecord.canonicalId.|(){}[0] + final val clientId // org.mobilenativefoundation.store6.mutations.storage/MutationIntentRecord.clientId|{}clientId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations.storage/MutationIntentRecord.clientId.|(){}[0] + final val clientSequence // org.mobilenativefoundation.store6.mutations.storage/MutationIntentRecord.clientSequence|{}clientSequence[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations.storage/MutationIntentRecord.clientSequence.|(){}[0] + final val createdAt // org.mobilenativefoundation.store6.mutations.storage/MutationIntentRecord.createdAt|{}createdAt[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations.storage/MutationIntentRecord.createdAt.|(){}[0] + final val idempotencyRoot // org.mobilenativefoundation.store6.mutations.storage/MutationIntentRecord.idempotencyRoot|{}idempotencyRoot[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations.storage/MutationIntentRecord.idempotencyRoot.|(){}[0] + final val mutationId // org.mobilenativefoundation.store6.mutations.storage/MutationIntentRecord.mutationId|{}mutationId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations.storage/MutationIntentRecord.mutationId.|(){}[0] + final val mutatorId // org.mobilenativefoundation.store6.mutations.storage/MutationIntentRecord.mutatorId|{}mutatorId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations.storage/MutationIntentRecord.mutatorId.|(){}[0] + final val mutatorVersion // org.mobilenativefoundation.store6.mutations.storage/MutationIntentRecord.mutatorVersion|{}mutatorVersion[0] + final fun (): kotlin/Int // org.mobilenativefoundation.store6.mutations.storage/MutationIntentRecord.mutatorVersion.|(){}[0] + final val namespace // org.mobilenativefoundation.store6.mutations.storage/MutationIntentRecord.namespace|{}namespace[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations.storage/MutationIntentRecord.namespace.|(){}[0] + final val recordVersion // org.mobilenativefoundation.store6.mutations.storage/MutationIntentRecord.recordVersion|{}recordVersion[0] + final fun (): kotlin/Int // org.mobilenativefoundation.store6.mutations.storage/MutationIntentRecord.recordVersion.|(){}[0] + final val rowId // org.mobilenativefoundation.store6.mutations.storage/MutationIntentRecord.rowId|{}rowId[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations.storage/MutationIntentRecord.rowId.|(){}[0] +} + +final class org.mobilenativefoundation.store6.mutations.storage/MutationKeyAliasRecord { // org.mobilenativefoundation.store6.mutations.storage/MutationKeyAliasRecord|null[0] + constructor (kotlin/String, kotlin/String, kotlin/String, kotlin/String, org.mobilenativefoundation.store6.mutations.storage/MutationAliasState, kotlin/String, kotlin/Long, kotlin/Long, kotlin/Long?) // org.mobilenativefoundation.store6.mutations.storage/MutationKeyAliasRecord.|(kotlin.String;kotlin.String;kotlin.String;kotlin.String;org.mobilenativefoundation.store6.mutations.storage.MutationAliasState;kotlin.String;kotlin.Long;kotlin.Long;kotlin.Long?){}[0] + + final val activatedAt // org.mobilenativefoundation.store6.mutations.storage/MutationKeyAliasRecord.activatedAt|{}activatedAt[0] + final fun (): kotlin/Long? // org.mobilenativefoundation.store6.mutations.storage/MutationKeyAliasRecord.activatedAt.|(){}[0] + final val createdAt // org.mobilenativefoundation.store6.mutations.storage/MutationKeyAliasRecord.createdAt|{}createdAt[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations.storage/MutationKeyAliasRecord.createdAt.|(){}[0] + final val createdByClientId // org.mobilenativefoundation.store6.mutations.storage/MutationKeyAliasRecord.createdByClientId|{}createdByClientId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations.storage/MutationKeyAliasRecord.createdByClientId.|(){}[0] + final val createdBySequence // org.mobilenativefoundation.store6.mutations.storage/MutationKeyAliasRecord.createdBySequence|{}createdBySequence[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations.storage/MutationKeyAliasRecord.createdBySequence.|(){}[0] + final val sourceCanonicalId // org.mobilenativefoundation.store6.mutations.storage/MutationKeyAliasRecord.sourceCanonicalId|{}sourceCanonicalId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations.storage/MutationKeyAliasRecord.sourceCanonicalId.|(){}[0] + final val sourceNamespace // org.mobilenativefoundation.store6.mutations.storage/MutationKeyAliasRecord.sourceNamespace|{}sourceNamespace[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations.storage/MutationKeyAliasRecord.sourceNamespace.|(){}[0] + final val state // org.mobilenativefoundation.store6.mutations.storage/MutationKeyAliasRecord.state|{}state[0] + final fun (): org.mobilenativefoundation.store6.mutations.storage/MutationAliasState // org.mobilenativefoundation.store6.mutations.storage/MutationKeyAliasRecord.state.|(){}[0] + final val targetCanonicalId // org.mobilenativefoundation.store6.mutations.storage/MutationKeyAliasRecord.targetCanonicalId|{}targetCanonicalId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations.storage/MutationKeyAliasRecord.targetCanonicalId.|(){}[0] + final val targetNamespace // org.mobilenativefoundation.store6.mutations.storage/MutationKeyAliasRecord.targetNamespace|{}targetNamespace[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations.storage/MutationKeyAliasRecord.targetNamespace.|(){}[0] +} + +final class org.mobilenativefoundation.store6.mutations.storage/MutationKeyTombstoneRecord { // org.mobilenativefoundation.store6.mutations.storage/MutationKeyTombstoneRecord|null[0] + constructor (kotlin/String, kotlin/String, kotlin/String, kotlin/Long, org.mobilenativefoundation.store6.mutations.storage/MutationTombstoneState, kotlin/Long, kotlin/Long?, kotlin/String?, kotlin/Long?, kotlin/Long?) // org.mobilenativefoundation.store6.mutations.storage/MutationKeyTombstoneRecord.|(kotlin.String;kotlin.String;kotlin.String;kotlin.Long;org.mobilenativefoundation.store6.mutations.storage.MutationTombstoneState;kotlin.Long;kotlin.Long?;kotlin.String?;kotlin.Long?;kotlin.Long?){}[0] + + final val activatedAt // org.mobilenativefoundation.store6.mutations.storage/MutationKeyTombstoneRecord.activatedAt|{}activatedAt[0] + final fun (): kotlin/Long? // org.mobilenativefoundation.store6.mutations.storage/MutationKeyTombstoneRecord.activatedAt.|(){}[0] + final val canonicalId // org.mobilenativefoundation.store6.mutations.storage/MutationKeyTombstoneRecord.canonicalId|{}canonicalId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations.storage/MutationKeyTombstoneRecord.canonicalId.|(){}[0] + final val createdAt // org.mobilenativefoundation.store6.mutations.storage/MutationKeyTombstoneRecord.createdAt|{}createdAt[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations.storage/MutationKeyTombstoneRecord.createdAt.|(){}[0] + final val createdByClientId // org.mobilenativefoundation.store6.mutations.storage/MutationKeyTombstoneRecord.createdByClientId|{}createdByClientId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations.storage/MutationKeyTombstoneRecord.createdByClientId.|(){}[0] + final val createdBySequence // org.mobilenativefoundation.store6.mutations.storage/MutationKeyTombstoneRecord.createdBySequence|{}createdBySequence[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations.storage/MutationKeyTombstoneRecord.createdBySequence.|(){}[0] + final val namespace // org.mobilenativefoundation.store6.mutations.storage/MutationKeyTombstoneRecord.namespace|{}namespace[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations.storage/MutationKeyTombstoneRecord.namespace.|(){}[0] + final val state // org.mobilenativefoundation.store6.mutations.storage/MutationKeyTombstoneRecord.state|{}state[0] + final fun (): org.mobilenativefoundation.store6.mutations.storage/MutationTombstoneState // org.mobilenativefoundation.store6.mutations.storage/MutationKeyTombstoneRecord.state.|(){}[0] + final val supersededAt // org.mobilenativefoundation.store6.mutations.storage/MutationKeyTombstoneRecord.supersededAt|{}supersededAt[0] + final fun (): kotlin/Long? // org.mobilenativefoundation.store6.mutations.storage/MutationKeyTombstoneRecord.supersededAt.|(){}[0] + final val supersededByClientId // org.mobilenativefoundation.store6.mutations.storage/MutationKeyTombstoneRecord.supersededByClientId|{}supersededByClientId[0] + final fun (): kotlin/String? // org.mobilenativefoundation.store6.mutations.storage/MutationKeyTombstoneRecord.supersededByClientId.|(){}[0] + final val supersededBySequence // org.mobilenativefoundation.store6.mutations.storage/MutationKeyTombstoneRecord.supersededBySequence|{}supersededBySequence[0] + final fun (): kotlin/Long? // org.mobilenativefoundation.store6.mutations.storage/MutationKeyTombstoneRecord.supersededBySequence.|(){}[0] +} + +final class org.mobilenativefoundation.store6.mutations/DeadLetter { // org.mobilenativefoundation.store6.mutations/DeadLetter|null[0] + final val attempts // org.mobilenativefoundation.store6.mutations/DeadLetter.attempts|{}attempts[0] + final fun (): kotlin/Int // org.mobilenativefoundation.store6.mutations/DeadLetter.attempts.|(){}[0] + final val canonicalId // org.mobilenativefoundation.store6.mutations/DeadLetter.canonicalId|{}canonicalId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations/DeadLetter.canonicalId.|(){}[0] + final val failure // org.mobilenativefoundation.store6.mutations/DeadLetter.failure|{}failure[0] + final fun (): org.mobilenativefoundation.store6.mutations/MutationFailure // org.mobilenativefoundation.store6.mutations/DeadLetter.failure.|(){}[0] + final val generation // org.mobilenativefoundation.store6.mutations/DeadLetter.generation|{}generation[0] + final fun (): kotlin/Int // org.mobilenativefoundation.store6.mutations/DeadLetter.generation.|(){}[0] + final val mutationId // org.mobilenativefoundation.store6.mutations/DeadLetter.mutationId|{}mutationId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations/DeadLetter.mutationId.|(){}[0] + final val mutatorId // org.mobilenativefoundation.store6.mutations/DeadLetter.mutatorId|{}mutatorId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations/DeadLetter.mutatorId.|(){}[0] + final val namespace // org.mobilenativefoundation.store6.mutations/DeadLetter.namespace|{}namespace[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations/DeadLetter.namespace.|(){}[0] + final val parkedAtEpochMillis // org.mobilenativefoundation.store6.mutations/DeadLetter.parkedAtEpochMillis|{}parkedAtEpochMillis[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations/DeadLetter.parkedAtEpochMillis.|(){}[0] +} + +final class org.mobilenativefoundation.store6.mutations/MutationAcknowledged : org.mobilenativefoundation.store6.mutations/MutationIntentEvent { // org.mobilenativefoundation.store6.mutations/MutationAcknowledged|null[0] + final val generation // org.mobilenativefoundation.store6.mutations/MutationAcknowledged.generation|{}generation[0] + final fun (): kotlin/Int // org.mobilenativefoundation.store6.mutations/MutationAcknowledged.generation.|(){}[0] + final val identity // org.mobilenativefoundation.store6.mutations/MutationAcknowledged.identity|{}identity[0] + final fun (): org.mobilenativefoundation.store6.mutations/MutationKeyIdentity // org.mobilenativefoundation.store6.mutations/MutationAcknowledged.identity.|(){}[0] + final val mutationId // org.mobilenativefoundation.store6.mutations/MutationAcknowledged.mutationId|{}mutationId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations/MutationAcknowledged.mutationId.|(){}[0] + final val occurredAtEpochMillis // org.mobilenativefoundation.store6.mutations/MutationAcknowledged.occurredAtEpochMillis|{}occurredAtEpochMillis[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations/MutationAcknowledged.occurredAtEpochMillis.|(){}[0] + final val presence // org.mobilenativefoundation.store6.mutations/MutationAcknowledged.presence|{}presence[0] + final fun (): org.mobilenativefoundation.store6.mutations/MutationPresenceState // org.mobilenativefoundation.store6.mutations/MutationAcknowledged.presence.|(){}[0] +} + +final class org.mobilenativefoundation.store6.mutations/MutationAdopted : org.mobilenativefoundation.store6.mutations/MutationIntentEvent { // org.mobilenativefoundation.store6.mutations/MutationAdopted|null[0] + final val generation // org.mobilenativefoundation.store6.mutations/MutationAdopted.generation|{}generation[0] + final fun (): kotlin/Int // org.mobilenativefoundation.store6.mutations/MutationAdopted.generation.|(){}[0] + final val identity // org.mobilenativefoundation.store6.mutations/MutationAdopted.identity|{}identity[0] + final fun (): org.mobilenativefoundation.store6.mutations/MutationKeyIdentity // org.mobilenativefoundation.store6.mutations/MutationAdopted.identity.|(){}[0] + final val mutationId // org.mobilenativefoundation.store6.mutations/MutationAdopted.mutationId|{}mutationId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations/MutationAdopted.mutationId.|(){}[0] + final val occurredAtEpochMillis // org.mobilenativefoundation.store6.mutations/MutationAdopted.occurredAtEpochMillis|{}occurredAtEpochMillis[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations/MutationAdopted.occurredAtEpochMillis.|(){}[0] + final val presence // org.mobilenativefoundation.store6.mutations/MutationAdopted.presence|{}presence[0] + final fun (): org.mobilenativefoundation.store6.mutations/MutationPresenceState // org.mobilenativefoundation.store6.mutations/MutationAdopted.presence.|(){}[0] +} + +final class org.mobilenativefoundation.store6.mutations/MutationAttempted : org.mobilenativefoundation.store6.mutations/MutationIntentEvent { // org.mobilenativefoundation.store6.mutations/MutationAttempted|null[0] + final val attempt // org.mobilenativefoundation.store6.mutations/MutationAttempted.attempt|{}attempt[0] + final fun (): kotlin/Int // org.mobilenativefoundation.store6.mutations/MutationAttempted.attempt.|(){}[0] + final val generation // org.mobilenativefoundation.store6.mutations/MutationAttempted.generation|{}generation[0] + final fun (): kotlin/Int // org.mobilenativefoundation.store6.mutations/MutationAttempted.generation.|(){}[0] + final val identity // org.mobilenativefoundation.store6.mutations/MutationAttempted.identity|{}identity[0] + final fun (): org.mobilenativefoundation.store6.mutations/MutationKeyIdentity // org.mobilenativefoundation.store6.mutations/MutationAttempted.identity.|(){}[0] + final val mutationId // org.mobilenativefoundation.store6.mutations/MutationAttempted.mutationId|{}mutationId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations/MutationAttempted.mutationId.|(){}[0] + final val occurredAtEpochMillis // org.mobilenativefoundation.store6.mutations/MutationAttempted.occurredAtEpochMillis|{}occurredAtEpochMillis[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations/MutationAttempted.occurredAtEpochMillis.|(){}[0] +} + +final class org.mobilenativefoundation.store6.mutations/MutationCheckpointConfirmed : org.mobilenativefoundation.store6.mutations/MutationEvent { // org.mobilenativefoundation.store6.mutations/MutationCheckpointConfirmed|null[0] + final val clientId // org.mobilenativefoundation.store6.mutations/MutationCheckpointConfirmed.clientId|{}clientId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations/MutationCheckpointConfirmed.clientId.|(){}[0] + final val confirmedThroughSequence // org.mobilenativefoundation.store6.mutations/MutationCheckpointConfirmed.confirmedThroughSequence|{}confirmedThroughSequence[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations/MutationCheckpointConfirmed.confirmedThroughSequence.|(){}[0] + final val occurredAtEpochMillis // org.mobilenativefoundation.store6.mutations/MutationCheckpointConfirmed.occurredAtEpochMillis|{}occurredAtEpochMillis[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations/MutationCheckpointConfirmed.occurredAtEpochMillis.|(){}[0] + final val requestedThroughSequence // org.mobilenativefoundation.store6.mutations/MutationCheckpointConfirmed.requestedThroughSequence|{}requestedThroughSequence[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations/MutationCheckpointConfirmed.requestedThroughSequence.|(){}[0] +} + +final class org.mobilenativefoundation.store6.mutations/MutationCheckpointFailed : org.mobilenativefoundation.store6.mutations/MutationEvent { // org.mobilenativefoundation.store6.mutations/MutationCheckpointFailed|null[0] + final val clientId // org.mobilenativefoundation.store6.mutations/MutationCheckpointFailed.clientId|{}clientId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations/MutationCheckpointFailed.clientId.|(){}[0] + final val failure // org.mobilenativefoundation.store6.mutations/MutationCheckpointFailed.failure|{}failure[0] + final fun (): org.mobilenativefoundation.store6.mutations/MutationFailure // org.mobilenativefoundation.store6.mutations/MutationCheckpointFailed.failure.|(){}[0] + final val occurredAtEpochMillis // org.mobilenativefoundation.store6.mutations/MutationCheckpointFailed.occurredAtEpochMillis|{}occurredAtEpochMillis[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations/MutationCheckpointFailed.occurredAtEpochMillis.|(){}[0] + final val requestedThroughSequence // org.mobilenativefoundation.store6.mutations/MutationCheckpointFailed.requestedThroughSequence|{}requestedThroughSequence[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations/MutationCheckpointFailed.requestedThroughSequence.|(){}[0] +} + +final class org.mobilenativefoundation.store6.mutations/MutationConflictObserved : org.mobilenativefoundation.store6.mutations/MutationIntentEvent { // org.mobilenativefoundation.store6.mutations/MutationConflictObserved|null[0] + final val generation // org.mobilenativefoundation.store6.mutations/MutationConflictObserved.generation|{}generation[0] + final fun (): kotlin/Int // org.mobilenativefoundation.store6.mutations/MutationConflictObserved.generation.|(){}[0] + final val identity // org.mobilenativefoundation.store6.mutations/MutationConflictObserved.identity|{}identity[0] + final fun (): org.mobilenativefoundation.store6.mutations/MutationKeyIdentity // org.mobilenativefoundation.store6.mutations/MutationConflictObserved.identity.|(){}[0] + final val mutationId // org.mobilenativefoundation.store6.mutations/MutationConflictObserved.mutationId|{}mutationId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations/MutationConflictObserved.mutationId.|(){}[0] + final val occurredAtEpochMillis // org.mobilenativefoundation.store6.mutations/MutationConflictObserved.occurredAtEpochMillis|{}occurredAtEpochMillis[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations/MutationConflictObserved.occurredAtEpochMillis.|(){}[0] + final val serverMeta // org.mobilenativefoundation.store6.mutations/MutationConflictObserved.serverMeta|{}serverMeta[0] + final fun (): org.mobilenativefoundation.store6.core/StoreMeta? // org.mobilenativefoundation.store6.mutations/MutationConflictObserved.serverMeta.|(){}[0] +} + +final class org.mobilenativefoundation.store6.mutations/MutationEffectApplied : org.mobilenativefoundation.store6.mutations/MutationIntentEvent { // org.mobilenativefoundation.store6.mutations/MutationEffectApplied|null[0] + final val effectIndex // org.mobilenativefoundation.store6.mutations/MutationEffectApplied.effectIndex|{}effectIndex[0] + final fun (): kotlin/Int // org.mobilenativefoundation.store6.mutations/MutationEffectApplied.effectIndex.|(){}[0] + final val generation // org.mobilenativefoundation.store6.mutations/MutationEffectApplied.generation|{}generation[0] + final fun (): kotlin/Int // org.mobilenativefoundation.store6.mutations/MutationEffectApplied.generation.|(){}[0] + final val identity // org.mobilenativefoundation.store6.mutations/MutationEffectApplied.identity|{}identity[0] + final fun (): org.mobilenativefoundation.store6.mutations/MutationKeyIdentity // org.mobilenativefoundation.store6.mutations/MutationEffectApplied.identity.|(){}[0] + final val mutationId // org.mobilenativefoundation.store6.mutations/MutationEffectApplied.mutationId|{}mutationId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations/MutationEffectApplied.mutationId.|(){}[0] + final val occurredAtEpochMillis // org.mobilenativefoundation.store6.mutations/MutationEffectApplied.occurredAtEpochMillis|{}occurredAtEpochMillis[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations/MutationEffectApplied.occurredAtEpochMillis.|(){}[0] +} + +final class org.mobilenativefoundation.store6.mutations/MutationEffectSkipped : org.mobilenativefoundation.store6.mutations/MutationIntentEvent { // org.mobilenativefoundation.store6.mutations/MutationEffectSkipped|null[0] + final val effectIndex // org.mobilenativefoundation.store6.mutations/MutationEffectSkipped.effectIndex|{}effectIndex[0] + final fun (): kotlin/Int // org.mobilenativefoundation.store6.mutations/MutationEffectSkipped.effectIndex.|(){}[0] + final val generation // org.mobilenativefoundation.store6.mutations/MutationEffectSkipped.generation|{}generation[0] + final fun (): kotlin/Int // org.mobilenativefoundation.store6.mutations/MutationEffectSkipped.generation.|(){}[0] + final val identity // org.mobilenativefoundation.store6.mutations/MutationEffectSkipped.identity|{}identity[0] + final fun (): org.mobilenativefoundation.store6.mutations/MutationKeyIdentity // org.mobilenativefoundation.store6.mutations/MutationEffectSkipped.identity.|(){}[0] + final val mutationId // org.mobilenativefoundation.store6.mutations/MutationEffectSkipped.mutationId|{}mutationId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations/MutationEffectSkipped.mutationId.|(){}[0] + final val occurredAtEpochMillis // org.mobilenativefoundation.store6.mutations/MutationEffectSkipped.occurredAtEpochMillis|{}occurredAtEpochMillis[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations/MutationEffectSkipped.occurredAtEpochMillis.|(){}[0] +} + +final class org.mobilenativefoundation.store6.mutations/MutationEnqueued : org.mobilenativefoundation.store6.mutations/MutationIntentEvent { // org.mobilenativefoundation.store6.mutations/MutationEnqueued|null[0] + final val clientSequence // org.mobilenativefoundation.store6.mutations/MutationEnqueued.clientSequence|{}clientSequence[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations/MutationEnqueued.clientSequence.|(){}[0] + final val identity // org.mobilenativefoundation.store6.mutations/MutationEnqueued.identity|{}identity[0] + final fun (): org.mobilenativefoundation.store6.mutations/MutationKeyIdentity // org.mobilenativefoundation.store6.mutations/MutationEnqueued.identity.|(){}[0] + final val mutationId // org.mobilenativefoundation.store6.mutations/MutationEnqueued.mutationId|{}mutationId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations/MutationEnqueued.mutationId.|(){}[0] + final val mutatorId // org.mobilenativefoundation.store6.mutations/MutationEnqueued.mutatorId|{}mutatorId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations/MutationEnqueued.mutatorId.|(){}[0] + final val occurredAtEpochMillis // org.mobilenativefoundation.store6.mutations/MutationEnqueued.occurredAtEpochMillis|{}occurredAtEpochMillis[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations/MutationEnqueued.occurredAtEpochMillis.|(){}[0] +} + +final class org.mobilenativefoundation.store6.mutations/MutationFailed : org.mobilenativefoundation.store6.mutations/MutationIntentEvent { // org.mobilenativefoundation.store6.mutations/MutationFailed|null[0] + final val failure // org.mobilenativefoundation.store6.mutations/MutationFailed.failure|{}failure[0] + final fun (): org.mobilenativefoundation.store6.mutations/MutationFailure // org.mobilenativefoundation.store6.mutations/MutationFailed.failure.|(){}[0] + final val generation // org.mobilenativefoundation.store6.mutations/MutationFailed.generation|{}generation[0] + final fun (): kotlin/Int // org.mobilenativefoundation.store6.mutations/MutationFailed.generation.|(){}[0] + final val identity // org.mobilenativefoundation.store6.mutations/MutationFailed.identity|{}identity[0] + final fun (): org.mobilenativefoundation.store6.mutations/MutationKeyIdentity // org.mobilenativefoundation.store6.mutations/MutationFailed.identity.|(){}[0] + final val mutationId // org.mobilenativefoundation.store6.mutations/MutationFailed.mutationId|{}mutationId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations/MutationFailed.mutationId.|(){}[0] + final val occurredAtEpochMillis // org.mobilenativefoundation.store6.mutations/MutationFailed.occurredAtEpochMillis|{}occurredAtEpochMillis[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations/MutationFailed.occurredAtEpochMillis.|(){}[0] + final val state // org.mobilenativefoundation.store6.mutations/MutationFailed.state|{}state[0] + final fun (): org.mobilenativefoundation.store6.mutations/MutationPendingState // org.mobilenativefoundation.store6.mutations/MutationFailed.state.|(){}[0] +} + +final class org.mobilenativefoundation.store6.mutations/MutationFailure { // org.mobilenativefoundation.store6.mutations/MutationFailure|null[0] + final val detail // org.mobilenativefoundation.store6.mutations/MutationFailure.detail|{}detail[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations/MutationFailure.detail.|(){}[0] + final val kind // org.mobilenativefoundation.store6.mutations/MutationFailure.kind|{}kind[0] + final fun (): org.mobilenativefoundation.store6.mutations/MutationFailureKind // org.mobilenativefoundation.store6.mutations/MutationFailure.kind.|(){}[0] + final val message // org.mobilenativefoundation.store6.mutations/MutationFailure.message|{}message[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations/MutationFailure.message.|(){}[0] + final val occurredAtEpochMillis // org.mobilenativefoundation.store6.mutations/MutationFailure.occurredAtEpochMillis|{}occurredAtEpochMillis[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations/MutationFailure.occurredAtEpochMillis.|(){}[0] +} + +final class org.mobilenativefoundation.store6.mutations/MutationKeyIdentity { // org.mobilenativefoundation.store6.mutations/MutationKeyIdentity|null[0] + final val canonicalId // org.mobilenativefoundation.store6.mutations/MutationKeyIdentity.canonicalId|{}canonicalId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations/MutationKeyIdentity.canonicalId.|(){}[0] + final val namespace // org.mobilenativefoundation.store6.mutations/MutationKeyIdentity.namespace|{}namespace[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations/MutationKeyIdentity.namespace.|(){}[0] +} + +final class org.mobilenativefoundation.store6.mutations/MutationParked : org.mobilenativefoundation.store6.mutations/MutationIntentEvent { // org.mobilenativefoundation.store6.mutations/MutationParked|null[0] + final val failure // org.mobilenativefoundation.store6.mutations/MutationParked.failure|{}failure[0] + final fun (): org.mobilenativefoundation.store6.mutations/MutationFailure // org.mobilenativefoundation.store6.mutations/MutationParked.failure.|(){}[0] + final val generation // org.mobilenativefoundation.store6.mutations/MutationParked.generation|{}generation[0] + final fun (): kotlin/Int // org.mobilenativefoundation.store6.mutations/MutationParked.generation.|(){}[0] + final val identity // org.mobilenativefoundation.store6.mutations/MutationParked.identity|{}identity[0] + final fun (): org.mobilenativefoundation.store6.mutations/MutationKeyIdentity // org.mobilenativefoundation.store6.mutations/MutationParked.identity.|(){}[0] + final val mutationId // org.mobilenativefoundation.store6.mutations/MutationParked.mutationId|{}mutationId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations/MutationParked.mutationId.|(){}[0] + final val occurredAtEpochMillis // org.mobilenativefoundation.store6.mutations/MutationParked.occurredAtEpochMillis|{}occurredAtEpochMillis[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations/MutationParked.occurredAtEpochMillis.|(){}[0] +} + +final class org.mobilenativefoundation.store6.mutations/MutationRetired : org.mobilenativefoundation.store6.mutations/MutationIntentEvent { // org.mobilenativefoundation.store6.mutations/MutationRetired|null[0] + final val generation // org.mobilenativefoundation.store6.mutations/MutationRetired.generation|{}generation[0] + final fun (): kotlin/Int // org.mobilenativefoundation.store6.mutations/MutationRetired.generation.|(){}[0] + final val identity // org.mobilenativefoundation.store6.mutations/MutationRetired.identity|{}identity[0] + final fun (): org.mobilenativefoundation.store6.mutations/MutationKeyIdentity // org.mobilenativefoundation.store6.mutations/MutationRetired.identity.|(){}[0] + final val mutationId // org.mobilenativefoundation.store6.mutations/MutationRetired.mutationId|{}mutationId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations/MutationRetired.mutationId.|(){}[0] + final val occurredAtEpochMillis // org.mobilenativefoundation.store6.mutations/MutationRetired.occurredAtEpochMillis|{}occurredAtEpochMillis[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations/MutationRetired.occurredAtEpochMillis.|(){}[0] + final val retiredThroughSequence // org.mobilenativefoundation.store6.mutations/MutationRetired.retiredThroughSequence|{}retiredThroughSequence[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations/MutationRetired.retiredThroughSequence.|(){}[0] +} + +final class org.mobilenativefoundation.store6.mutations/MutationRetirement { // org.mobilenativefoundation.store6.mutations/MutationRetirement|null[0] + final val clientId // org.mobilenativefoundation.store6.mutations/MutationRetirement.clientId|{}clientId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations/MutationRetirement.clientId.|(){}[0] + final val retiredThroughSequence // org.mobilenativefoundation.store6.mutations/MutationRetirement.retiredThroughSequence|{}retiredThroughSequence[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations/MutationRetirement.retiredThroughSequence.|(){}[0] +} + +final class org.mobilenativefoundation.store6.mutations/MutationRetirementAck { // org.mobilenativefoundation.store6.mutations/MutationRetirementAck|null[0] + constructor (kotlin/Long) // org.mobilenativefoundation.store6.mutations/MutationRetirementAck.|(kotlin.Long){}[0] + + final val confirmedThroughSequence // org.mobilenativefoundation.store6.mutations/MutationRetirementAck.confirmedThroughSequence|{}confirmedThroughSequence[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations/MutationRetirementAck.confirmedThroughSequence.|(){}[0] +} + +final class org.mobilenativefoundation.store6.mutations/PendingIntent { // org.mobilenativefoundation.store6.mutations/PendingIntent|null[0] + final val attempt // org.mobilenativefoundation.store6.mutations/PendingIntent.attempt|{}attempt[0] + final fun (): kotlin/Int // org.mobilenativefoundation.store6.mutations/PendingIntent.attempt.|(){}[0] + final val canonicalId // org.mobilenativefoundation.store6.mutations/PendingIntent.canonicalId|{}canonicalId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations/PendingIntent.canonicalId.|(){}[0] + final val createdAtEpochMillis // org.mobilenativefoundation.store6.mutations/PendingIntent.createdAtEpochMillis|{}createdAtEpochMillis[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.mutations/PendingIntent.createdAtEpochMillis.|(){}[0] + final val mutationId // org.mobilenativefoundation.store6.mutations/PendingIntent.mutationId|{}mutationId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations/PendingIntent.mutationId.|(){}[0] + final val mutatorId // org.mobilenativefoundation.store6.mutations/PendingIntent.mutatorId|{}mutatorId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations/PendingIntent.mutatorId.|(){}[0] + final val namespace // org.mobilenativefoundation.store6.mutations/PendingIntent.namespace|{}namespace[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations/PendingIntent.namespace.|(){}[0] + final val state // org.mobilenativefoundation.store6.mutations/PendingIntent.state|{}state[0] + final fun (): org.mobilenativefoundation.store6.mutations/MutationPendingState // org.mobilenativefoundation.store6.mutations/PendingIntent.state.|(){}[0] +} + +final class org.mobilenativefoundation.store6.mutations/PoisonedIntent { // org.mobilenativefoundation.store6.mutations/PoisonedIntent|null[0] + final val failure // org.mobilenativefoundation.store6.mutations/PoisonedIntent.failure|{}failure[0] + final fun (): kotlin/Throwable // org.mobilenativefoundation.store6.mutations/PoisonedIntent.failure.|(){}[0] + final val mutationId // org.mobilenativefoundation.store6.mutations/PoisonedIntent.mutationId|{}mutationId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations/PoisonedIntent.mutationId.|(){}[0] + final val mutatorId // org.mobilenativefoundation.store6.mutations/PoisonedIntent.mutatorId|{}mutatorId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.mutations/PoisonedIntent.mutatorId.|(){}[0] +} + +final fun <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> org.mobilenativefoundation.store6.mutations/mutationStore(org.mobilenativefoundation.store6.mutations/MutatorRegistry<#A, #B>, org.mobilenativefoundation.store6.mutations/MutationServer<#A, #B>, org.mobilenativefoundation.store6.mutations/MutationKeyResolver<#A>, kotlin/Int, org.mobilenativefoundation.store6.mutations/MutationCodec<#B>, kotlin/Function1, kotlin/Unit>): org.mobilenativefoundation.store6.mutations/MutationStore<#A, #B> // org.mobilenativefoundation.store6.mutations/mutationStore|mutationStore(org.mobilenativefoundation.store6.mutations.MutatorRegistry<0:0,0:1>;org.mobilenativefoundation.store6.mutations.MutationServer<0:0,0:1>;org.mobilenativefoundation.store6.mutations.MutationKeyResolver<0:0>;kotlin.Int;org.mobilenativefoundation.store6.mutations.MutationCodec<0:1>;kotlin.Function1,kotlin.Unit>){0§;1§}[0] +final fun <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> org.mobilenativefoundation.store6.mutations/mutatorRegistry(kotlin/Function1, kotlin/Unit>): org.mobilenativefoundation.store6.mutations/MutatorRegistry<#A, #B> // org.mobilenativefoundation.store6.mutations/mutatorRegistry|mutatorRegistry(kotlin.Function1,kotlin.Unit>){0§;1§}[0] diff --git a/store6-mutations/api/swift/objc/Store6Mutations.h b/store6-mutations/api/swift/objc/Store6Mutations.h new file mode 100644 index 000000000..167bd2b3a --- /dev/null +++ b/store6-mutations/api/swift/objc/Store6Mutations.h @@ -0,0 +1,2931 @@ +#import +#import +#import +#import +#import +#import +#import + +@class Store6MutationsDeadLetter, Store6MutationsKotlinArray, Store6MutationsKotlinByteArray, Store6MutationsKotlinByteIterator, Store6MutationsKotlinEnum, Store6MutationsKotlinEnumCompanion, Store6MutationsKotlinException, Store6MutationsKotlinIllegalStateException, Store6MutationsKotlinRuntimeException, Store6MutationsKotlinThrowable, Store6MutationsKotlinUnit, Store6MutationsMutationAckRecord, Store6MutationsMutationAliasState, Store6MutationsMutationAttemptRecord, Store6MutationsMutationClientRecord, Store6MutationsMutationConflictBuilder, Store6MutationsMutationConflictResolutionServerWins, Store6MutationsMutationEffectDisposition, Store6MutationsMutationEffectKind, Store6MutationsMutationEffectRecord, Store6MutationsMutationExecutionPhase, Store6MutationsMutationExecutionRecord, Store6MutationsMutationFailure, Store6MutationsMutationFailureKind, Store6MutationsMutationFailureRecord, Store6MutationsMutationIntentRecord, Store6MutationsMutationKeyAliasRecord, Store6MutationsMutationKeyIdentity, Store6MutationsMutationKeyTombstoneRecord, Store6MutationsMutationPendingState, Store6MutationsMutationPreconditionCandidate, Store6MutationsMutationPresenceAbsent, Store6MutationsMutationPresenceState, Store6MutationsMutationPush, Store6MutationsMutationRetirement, Store6MutationsMutationRetirementAck, Store6MutationsMutationStore, Store6MutationsMutationStoreBuilder, Store6MutationsMutationTombstoneState, Store6MutationsMutatorRef, Store6MutationsMutatorRegistry, Store6MutationsMutatorRegistryBuilder, Store6MutationsPendingIntent, Store6MutationsStaleSet, Store6MutationsStore6_coreFreshnessContext, Store6MutationsStore6_coreKeyStatus, Store6MutationsStore6_coreOrigin, Store6MutationsStore6_coreStoreError, Store6MutationsStore6_coreStoreNamespace; + +@protocol Store6MutationsKotlinComparable, Store6MutationsKotlinFunction, Store6MutationsKotlinIterator, Store6MutationsKotlinSuspendFunction1, Store6MutationsKotlinx_coroutines_coreFlow, Store6MutationsKotlinx_coroutines_coreFlowCollector, Store6MutationsKotlinx_coroutines_coreSharedFlow, Store6MutationsMutationAck, Store6MutationsMutationCodec, Store6MutationsMutationConflictResolution, Store6MutationsMutationEvent, Store6MutationsMutationIntentEvent, Store6MutationsMutationJournalStorage, Store6MutationsMutationJournalTransaction, Store6MutationsMutationKeyResolver, Store6MutationsMutationPresence, Store6MutationsMutationServer, Store6MutationsStore6_coreBookkeeper, Store6MutationsStore6_coreFetchPlan, Store6MutationsStore6_coreFetcher, Store6MutationsStore6_coreFetcherResult, Store6MutationsStore6_coreFreshness, Store6MutationsStore6_coreFreshnessValidator, Store6MutationsStore6_coreSourceOfTruth, Store6MutationsStore6_coreStore, Store6MutationsStore6_coreStoreKey, Store6MutationsStore6_coreStoreMeta, Store6MutationsStore6_coreStoreTelemetry, Store6MutationsStore6_coreWallClock; + +NS_ASSUME_NONNULL_BEGIN +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wunknown-warning-option" +#pragma clang diagnostic ignored "-Wincompatible-property-type" +#pragma clang diagnostic ignored "-Wnullability" + +#pragma push_macro("_Nullable_result") +#if !__has_feature(nullability_nullable_result) +#undef _Nullable_result +#define _Nullable_result _Nullable +#endif + +__attribute__((swift_name("KotlinBase"))) +@interface Store6MutationsBase : NSObject +- (instancetype)init __attribute__((unavailable)); ++ (instancetype)new __attribute__((unavailable)); ++ (void)initialize __attribute__((objc_requires_super)); +@end + +@interface Store6MutationsBase (Store6MutationsBaseCopying) +@end + +__attribute__((swift_name("KotlinMutableSet"))) +@interface Store6MutationsMutableSet : NSMutableSet +@end + +__attribute__((swift_name("KotlinMutableDictionary"))) +@interface Store6MutationsMutableDictionary : NSMutableDictionary +@end + +@interface NSError (NSErrorStore6MutationsKotlinException) +@property (readonly) id _Nullable kotlinException; +@end + +__attribute__((swift_name("KotlinNumber"))) +@interface Store6MutationsNumber : NSNumber +- (instancetype)initWithChar:(char)value __attribute__((unavailable)); +- (instancetype)initWithUnsignedChar:(unsigned char)value __attribute__((unavailable)); +- (instancetype)initWithShort:(short)value __attribute__((unavailable)); +- (instancetype)initWithUnsignedShort:(unsigned short)value __attribute__((unavailable)); +- (instancetype)initWithInt:(int)value __attribute__((unavailable)); +- (instancetype)initWithUnsignedInt:(unsigned int)value __attribute__((unavailable)); +- (instancetype)initWithLong:(long)value __attribute__((unavailable)); +- (instancetype)initWithUnsignedLong:(unsigned long)value __attribute__((unavailable)); +- (instancetype)initWithLongLong:(long long)value __attribute__((unavailable)); +- (instancetype)initWithUnsignedLongLong:(unsigned long long)value __attribute__((unavailable)); +- (instancetype)initWithFloat:(float)value __attribute__((unavailable)); +- (instancetype)initWithDouble:(double)value __attribute__((unavailable)); +- (instancetype)initWithBool:(BOOL)value __attribute__((unavailable)); +- (instancetype)initWithInteger:(NSInteger)value __attribute__((unavailable)); +- (instancetype)initWithUnsignedInteger:(NSUInteger)value __attribute__((unavailable)); ++ (instancetype)numberWithChar:(char)value __attribute__((unavailable)); ++ (instancetype)numberWithUnsignedChar:(unsigned char)value __attribute__((unavailable)); ++ (instancetype)numberWithShort:(short)value __attribute__((unavailable)); ++ (instancetype)numberWithUnsignedShort:(unsigned short)value __attribute__((unavailable)); ++ (instancetype)numberWithInt:(int)value __attribute__((unavailable)); ++ (instancetype)numberWithUnsignedInt:(unsigned int)value __attribute__((unavailable)); ++ (instancetype)numberWithLong:(long)value __attribute__((unavailable)); ++ (instancetype)numberWithUnsignedLong:(unsigned long)value __attribute__((unavailable)); ++ (instancetype)numberWithLongLong:(long long)value __attribute__((unavailable)); ++ (instancetype)numberWithUnsignedLongLong:(unsigned long long)value __attribute__((unavailable)); ++ (instancetype)numberWithFloat:(float)value __attribute__((unavailable)); ++ (instancetype)numberWithDouble:(double)value __attribute__((unavailable)); ++ (instancetype)numberWithBool:(BOOL)value __attribute__((unavailable)); ++ (instancetype)numberWithInteger:(NSInteger)value __attribute__((unavailable)); ++ (instancetype)numberWithUnsignedInteger:(NSUInteger)value __attribute__((unavailable)); +@end + +__attribute__((swift_name("KotlinByte"))) +@interface Store6MutationsByte : Store6MutationsNumber +- (instancetype)initWithChar:(char)value; ++ (instancetype)numberWithChar:(char)value; +@end + +__attribute__((swift_name("KotlinUByte"))) +@interface Store6MutationsUByte : Store6MutationsNumber +- (instancetype)initWithUnsignedChar:(unsigned char)value; ++ (instancetype)numberWithUnsignedChar:(unsigned char)value; +@end + +__attribute__((swift_name("KotlinShort"))) +@interface Store6MutationsShort : Store6MutationsNumber +- (instancetype)initWithShort:(short)value; ++ (instancetype)numberWithShort:(short)value; +@end + +__attribute__((swift_name("KotlinUShort"))) +@interface Store6MutationsUShort : Store6MutationsNumber +- (instancetype)initWithUnsignedShort:(unsigned short)value; ++ (instancetype)numberWithUnsignedShort:(unsigned short)value; +@end + +__attribute__((swift_name("KotlinInt"))) +@interface Store6MutationsInt : Store6MutationsNumber +- (instancetype)initWithInt:(int)value; ++ (instancetype)numberWithInt:(int)value; +@end + +__attribute__((swift_name("KotlinUInt"))) +@interface Store6MutationsUInt : Store6MutationsNumber +- (instancetype)initWithUnsignedInt:(unsigned int)value; ++ (instancetype)numberWithUnsignedInt:(unsigned int)value; +@end + +__attribute__((swift_name("KotlinLong"))) +@interface Store6MutationsLong : Store6MutationsNumber +- (instancetype)initWithLongLong:(long long)value; ++ (instancetype)numberWithLongLong:(long long)value; +@end + +__attribute__((swift_name("KotlinULong"))) +@interface Store6MutationsULong : Store6MutationsNumber +- (instancetype)initWithUnsignedLongLong:(unsigned long long)value; ++ (instancetype)numberWithUnsignedLongLong:(unsigned long long)value; +@end + +__attribute__((swift_name("KotlinFloat"))) +@interface Store6MutationsFloat : Store6MutationsNumber +- (instancetype)initWithFloat:(float)value; ++ (instancetype)numberWithFloat:(float)value; +@end + +__attribute__((swift_name("KotlinDouble"))) +@interface Store6MutationsDouble : Store6MutationsNumber +- (instancetype)initWithDouble:(double)value; ++ (instancetype)numberWithDouble:(double)value; +@end + +__attribute__((swift_name("KotlinBoolean"))) +@interface Store6MutationsBoolean : Store6MutationsNumber +- (instancetype)initWithBool:(BOOL)value; ++ (instancetype)numberWithBool:(BOOL)value; +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("DeadLetter"))) +@interface Store6MutationsDeadLetter : Store6MutationsBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int32_t attempts __attribute__((swift_name("attempts"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *canonicalId __attribute__((swift_name("canonicalId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsMutationFailure *failure __attribute__((swift_name("failure"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int32_t generation __attribute__((swift_name("generation"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *mutationId __attribute__((swift_name("mutationId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *mutatorId __attribute__((swift_name("mutatorId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly, getter=namespace) NSString *namespace_ __attribute__((swift_name("namespace_"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t parkedAtEpochMillis __attribute__((swift_name("parkedAtEpochMillis"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((swift_name("MutationAck"))) +@protocol Store6MutationsMutationAck +@required + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString * _Nullable etag __attribute__((swift_name("etag"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationAbsentAck"))) +@interface Store6MutationsMutationAbsentAck : Store6MutationsBase +- (instancetype)initWithEtag:(NSString * _Nullable)etag __attribute__((swift_name("init(etag:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString * _Nullable etag __attribute__((swift_name("etag"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((swift_name("MutationEvent"))) +@protocol Store6MutationsMutationEvent +@required + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t occurredAtEpochMillis __attribute__((swift_name("occurredAtEpochMillis"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((swift_name("MutationIntentEvent"))) +@protocol Store6MutationsMutationIntentEvent +@required + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsMutationKeyIdentity *identity __attribute__((swift_name("identity"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *mutationId __attribute__((swift_name("mutationId"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationAcknowledged"))) +@interface Store6MutationsMutationAcknowledged : Store6MutationsBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int32_t generation __attribute__((swift_name("generation"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsMutationKeyIdentity *identity __attribute__((swift_name("identity"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *mutationId __attribute__((swift_name("mutationId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t occurredAtEpochMillis __attribute__((swift_name("occurredAtEpochMillis"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsMutationPresenceState *presence __attribute__((swift_name("presence"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationAdopted"))) +@interface Store6MutationsMutationAdopted : Store6MutationsBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int32_t generation __attribute__((swift_name("generation"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsMutationKeyIdentity *identity __attribute__((swift_name("identity"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *mutationId __attribute__((swift_name("mutationId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t occurredAtEpochMillis __attribute__((swift_name("occurredAtEpochMillis"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsMutationPresenceState *presence __attribute__((swift_name("presence"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationAttempted"))) +@interface Store6MutationsMutationAttempted : Store6MutationsBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int32_t attempt __attribute__((swift_name("attempt"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int32_t generation __attribute__((swift_name("generation"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsMutationKeyIdentity *identity __attribute__((swift_name("identity"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *mutationId __attribute__((swift_name("mutationId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t occurredAtEpochMillis __attribute__((swift_name("occurredAtEpochMillis"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationCheckpointConfirmed"))) +@interface Store6MutationsMutationCheckpointConfirmed : Store6MutationsBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *clientId __attribute__((swift_name("clientId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t confirmedThroughSequence __attribute__((swift_name("confirmedThroughSequence"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t occurredAtEpochMillis __attribute__((swift_name("occurredAtEpochMillis"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t requestedThroughSequence __attribute__((swift_name("requestedThroughSequence"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationCheckpointFailed"))) +@interface Store6MutationsMutationCheckpointFailed : Store6MutationsBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *clientId __attribute__((swift_name("clientId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsMutationFailure *failure __attribute__((swift_name("failure"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t occurredAtEpochMillis __attribute__((swift_name("occurredAtEpochMillis"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t requestedThroughSequence __attribute__((swift_name("requestedThroughSequence"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((swift_name("MutationCodec"))) +@protocol Store6MutationsMutationCodec +@required + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (id)decodeVersion:(int32_t)version bytes:(Store6MutationsKotlinByteArray *)bytes __attribute__((swift_name("decode(version:bytes:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (Store6MutationsKotlinByteArray *)encodeValue:(id)value __attribute__((swift_name("encode(value:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationConflictBuilder"))) +@interface Store6MutationsMutationConflictBuilder : Store6MutationsBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)mergeMerge:(id (^)(id, id, id))merge __attribute__((swift_name("merge(merge:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)preconditionSelect:(id _Nullable (^)(Store6MutationsMutationPreconditionCandidate *))select __attribute__((swift_name("precondition(select:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationConflictObserved"))) +@interface Store6MutationsMutationConflictObserved : Store6MutationsBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int32_t generation __attribute__((swift_name("generation"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsMutationKeyIdentity *identity __attribute__((swift_name("identity"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *mutationId __attribute__((swift_name("mutationId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t occurredAtEpochMillis __attribute__((swift_name("occurredAtEpochMillis"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) id _Nullable serverMeta __attribute__((swift_name("serverMeta"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((swift_name("MutationConflictResolution"))) +@protocol Store6MutationsMutationConflictResolution +@required +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationConflictResolutionRetry"))) +@interface Store6MutationsMutationConflictResolutionRetry : Store6MutationsBase +- (instancetype)initWithValue:(id)value __attribute__((swift_name("init(value:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) id value __attribute__((swift_name("value"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationConflictResolutionServerWins"))) +@interface Store6MutationsMutationConflictResolutionServerWins : Store6MutationsBase ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)serverWins __attribute__((swift_name("init()"))); +@property (class, readonly, getter=shared) Store6MutationsMutationConflictResolutionServerWins *shared __attribute__((swift_name("shared"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationEffectApplied"))) +@interface Store6MutationsMutationEffectApplied : Store6MutationsBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int32_t effectIndex __attribute__((swift_name("effectIndex"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int32_t generation __attribute__((swift_name("generation"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsMutationKeyIdentity *identity __attribute__((swift_name("identity"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *mutationId __attribute__((swift_name("mutationId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t occurredAtEpochMillis __attribute__((swift_name("occurredAtEpochMillis"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationEffectSkipped"))) +@interface Store6MutationsMutationEffectSkipped : Store6MutationsBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int32_t effectIndex __attribute__((swift_name("effectIndex"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int32_t generation __attribute__((swift_name("generation"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsMutationKeyIdentity *identity __attribute__((swift_name("identity"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *mutationId __attribute__((swift_name("mutationId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t occurredAtEpochMillis __attribute__((swift_name("occurredAtEpochMillis"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationEnqueued"))) +@interface Store6MutationsMutationEnqueued : Store6MutationsBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t clientSequence __attribute__((swift_name("clientSequence"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsMutationKeyIdentity *identity __attribute__((swift_name("identity"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *mutationId __attribute__((swift_name("mutationId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *mutatorId __attribute__((swift_name("mutatorId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t occurredAtEpochMillis __attribute__((swift_name("occurredAtEpochMillis"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationFailed"))) +@interface Store6MutationsMutationFailed : Store6MutationsBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsMutationFailure *failure __attribute__((swift_name("failure"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int32_t generation __attribute__((swift_name("generation"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsMutationKeyIdentity *identity __attribute__((swift_name("identity"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *mutationId __attribute__((swift_name("mutationId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t occurredAtEpochMillis __attribute__((swift_name("occurredAtEpochMillis"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsMutationPendingState *state __attribute__((swift_name("state"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationFailure"))) +@interface Store6MutationsMutationFailure : Store6MutationsBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *detail __attribute__((swift_name("detail"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsMutationFailureKind *kind __attribute__((swift_name("kind"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *message __attribute__((swift_name("message"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t occurredAtEpochMillis __attribute__((swift_name("occurredAtEpochMillis"))); +@end + +__attribute__((swift_name("KotlinComparable"))) +@protocol Store6MutationsKotlinComparable +@required +- (int32_t)compareToOther:(id _Nullable)other __attribute__((swift_name("compareTo(other:)"))); +@end + +__attribute__((swift_name("KotlinEnum"))) +@interface Store6MutationsKotlinEnum : Store6MutationsBase +- (instancetype)initWithName:(NSString *)name ordinal:(int32_t)ordinal __attribute__((swift_name("init(name:ordinal:)"))) __attribute__((objc_designated_initializer)); +@property (class, readonly, getter=companion) Store6MutationsKotlinEnumCompanion *companion __attribute__((swift_name("companion"))); +- (int32_t)compareToOther:(E)other __attribute__((swift_name("compareTo(other:)"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@property (readonly) NSString *name __attribute__((swift_name("name"))); +@property (readonly) int32_t ordinal __attribute__((swift_name("ordinal"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationFailureKind"))) +@interface Store6MutationsMutationFailureKind : Store6MutationsKotlinEnum ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); +- (instancetype)initWithName:(NSString *)name ordinal:(int32_t)ordinal __attribute__((swift_name("init(name:ordinal:)"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable)); +@property (class, readonly) Store6MutationsMutationFailureKind *identity __attribute__((swift_name("identity"))); +@property (class, readonly) Store6MutationsMutationFailureKind *codec __attribute__((swift_name("codec"))); +@property (class, readonly) Store6MutationsMutationFailureKind *projection __attribute__((swift_name("projection"))); +@property (class, readonly) Store6MutationsMutationFailureKind *protocol __attribute__((swift_name("protocol"))); +@property (class, readonly) Store6MutationsMutationFailureKind *conflict __attribute__((swift_name("conflict"))); +@property (class, readonly) Store6MutationsMutationFailureKind *transport __attribute__((swift_name("transport"))); +@property (class, readonly) Store6MutationsMutationFailureKind *adoption __attribute__((swift_name("adoption"))); +@property (class, readonly) Store6MutationsMutationFailureKind *effect __attribute__((swift_name("effect"))); +@property (class, readonly) Store6MutationsMutationFailureKind *persistence __attribute__((swift_name("persistence"))); ++ (Store6MutationsKotlinArray *)values __attribute__((swift_name("values()"))); +@property (class, readonly) NSArray *entries __attribute__((swift_name("entries"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationKeyIdentity"))) +@interface Store6MutationsMutationKeyIdentity : Store6MutationsBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *canonicalId __attribute__((swift_name("canonicalId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly, getter=namespace) NSString *namespace_ __attribute__((swift_name("namespace_"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((swift_name("MutationKeyResolver"))) +@protocol Store6MutationsMutationKeyResolver +@required + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)resolveIdentity:(Store6MutationsMutationKeyIdentity *)identity completionHandler:(void (^)(id _Nullable_result, NSError * _Nullable))completionHandler __attribute__((swift_name("resolve(identity:completionHandler:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationParked"))) +@interface Store6MutationsMutationParked : Store6MutationsBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsMutationFailure *failure __attribute__((swift_name("failure"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int32_t generation __attribute__((swift_name("generation"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsMutationKeyIdentity *identity __attribute__((swift_name("identity"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *mutationId __attribute__((swift_name("mutationId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t occurredAtEpochMillis __attribute__((swift_name("occurredAtEpochMillis"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationPendingState"))) +@interface Store6MutationsMutationPendingState : Store6MutationsKotlinEnum ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); +- (instancetype)initWithName:(NSString *)name ordinal:(int32_t)ordinal __attribute__((swift_name("init(name:ordinal:)"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable)); +@property (class, readonly) Store6MutationsMutationPendingState *pending __attribute__((swift_name("pending"))); +@property (class, readonly) Store6MutationsMutationPendingState *inflight __attribute__((swift_name("inflight"))); +@property (class, readonly) Store6MutationsMutationPendingState *refreshing __attribute__((swift_name("refreshing"))); +@property (class, readonly) Store6MutationsMutationPendingState *adopting __attribute__((swift_name("adopting"))); +@property (class, readonly) Store6MutationsMutationPendingState *applyingEffects __attribute__((swift_name("applyingEffects"))); ++ (Store6MutationsKotlinArray *)values __attribute__((swift_name("values()"))); +@property (class, readonly) NSArray *entries __attribute__((swift_name("entries"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationPreconditionCandidate"))) +@interface Store6MutationsMutationPreconditionCandidate : Store6MutationsBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) id base __attribute__((swift_name("base"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) id _Nullable capturedMeta __attribute__((swift_name("capturedMeta"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int32_t generation __attribute__((swift_name("generation"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsMutationKeyIdentity *identity __attribute__((swift_name("identity"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) K key __attribute__((swift_name("key"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) id mine __attribute__((swift_name("mine"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *mutationId __attribute__((swift_name("mutationId"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((swift_name("MutationPresence"))) +@protocol Store6MutationsMutationPresence +@required +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationPresenceAbsent"))) +@interface Store6MutationsMutationPresenceAbsent : Store6MutationsBase ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)absent __attribute__((swift_name("init()"))); +@property (class, readonly, getter=shared) Store6MutationsMutationPresenceAbsent *shared __attribute__((swift_name("shared"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationPresencePresent"))) +@interface Store6MutationsMutationPresencePresent : Store6MutationsBase +- (instancetype)initWithValue:(V)value __attribute__((swift_name("init(value:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) V value __attribute__((swift_name("value"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationPresenceState"))) +@interface Store6MutationsMutationPresenceState : Store6MutationsKotlinEnum ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); +- (instancetype)initWithName:(NSString *)name ordinal:(int32_t)ordinal __attribute__((swift_name("init(name:ordinal:)"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable)); +@property (class, readonly) Store6MutationsMutationPresenceState *present __attribute__((swift_name("present"))); +@property (class, readonly) Store6MutationsMutationPresenceState *absent __attribute__((swift_name("absent"))); ++ (Store6MutationsKotlinArray *)values __attribute__((swift_name("values()"))); +@property (class, readonly) NSArray *entries __attribute__((swift_name("entries"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationPresentAck"))) +@interface Store6MutationsMutationPresentAck : Store6MutationsBase +- (instancetype)initWithAuthoritative:(V)authoritative etag:(NSString * _Nullable)etag canonicalKey:(K _Nullable)canonicalKey __attribute__((swift_name("init(authoritative:etag:canonicalKey:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) V authoritative __attribute__((swift_name("authoritative"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) K _Nullable canonicalKey __attribute__((swift_name("canonicalKey"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString * _Nullable etag __attribute__((swift_name("etag"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationPush"))) +@interface Store6MutationsMutationPush : Store6MutationsBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) id base __attribute__((swift_name("base"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) id _Nullable baseMeta __attribute__((swift_name("baseMeta"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *clientId __attribute__((swift_name("clientId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t clientSequence __attribute__((swift_name("clientSequence"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int32_t generation __attribute__((swift_name("generation"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *idempotencyKey __attribute__((swift_name("idempotencyKey"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsMutationKeyIdentity *identity __attribute__((swift_name("identity"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) K key __attribute__((swift_name("key"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) id mine __attribute__((swift_name("mine"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *mutationId __attribute__((swift_name("mutationId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t retiredThroughSequence __attribute__((swift_name("retiredThroughSequence"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int32_t valueCodecVersion __attribute__((swift_name("valueCodecVersion"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationRetired"))) +@interface Store6MutationsMutationRetired : Store6MutationsBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int32_t generation __attribute__((swift_name("generation"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsMutationKeyIdentity *identity __attribute__((swift_name("identity"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *mutationId __attribute__((swift_name("mutationId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t occurredAtEpochMillis __attribute__((swift_name("occurredAtEpochMillis"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t retiredThroughSequence __attribute__((swift_name("retiredThroughSequence"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationRetirement"))) +@interface Store6MutationsMutationRetirement : Store6MutationsBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *clientId __attribute__((swift_name("clientId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t retiredThroughSequence __attribute__((swift_name("retiredThroughSequence"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationRetirementAck"))) +@interface Store6MutationsMutationRetirementAck : Store6MutationsBase +- (instancetype)initWithConfirmedThroughSequence:(int64_t)confirmedThroughSequence __attribute__((swift_name("init(confirmedThroughSequence:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t confirmedThroughSequence __attribute__((swift_name("confirmedThroughSequence"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((swift_name("MutationServer"))) +@protocol Store6MutationsMutationServer +@required + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)pushRequest:(Store6MutationsMutationPush, id> *)request completionHandler:(void (^)(id _Nullable, NSError * _Nullable))completionHandler __attribute__((swift_name("push(request:completionHandler:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)retireRequest:(Store6MutationsMutationRetirement *)request completionHandler:(void (^)(Store6MutationsMutationRetirementAck * _Nullable, NSError * _Nullable))completionHandler __attribute__((swift_name("retire(request:completionHandler:)"))); +@end + + +/** + * @note annotations + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("Store6_coreStore"))) +@protocol Store6MutationsStore6_coreStore +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)clearKey:(id)key completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("clear(key:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)clearAllWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("clearAll(completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)clearNamespaceNamespace:(Store6MutationsStore6_coreStoreNamespace *)namespace_ completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("clearNamespace(namespace:completionHandler:)"))); +- (void)close __attribute__((swift_name("close()"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)getKey:(id)key freshness:(id)freshness completionHandler:(void (^)(id _Nullable, NSError * _Nullable))completionHandler __attribute__((swift_name("get(key:freshness:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)invalidateKey:(id)key completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("invalidate(key:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)invalidateAllWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("invalidateAll(completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)invalidateNamespaceNamespace:(Store6MutationsStore6_coreStoreNamespace *)namespace_ completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("invalidateNamespace(namespace:completionHandler:)"))); +- (id)streamKey:(id)key freshness:(id)freshness __attribute__((swift_name("stream(key:freshness:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationStore"))) +@interface Store6MutationsMutationStore : Store6MutationsBase + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)clearKey:(K)key completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("clear(key:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)clearAllWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("clearAll(completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)clearNamespaceNamespace:(Store6MutationsStore6_coreStoreNamespace *)namespace_ completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("clearNamespace(namespace:completionHandler:)"))); +- (void)close __attribute__((swift_name("close()"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)deadLettersWithCompletionHandler:(void (^)(NSArray * _Nullable, NSError * _Nullable))completionHandler __attribute__((swift_name("deadLetters(completionHandler:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)drainWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("drain(completionHandler:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)drainKey:(K)key completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("drain(key:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)getKey:(K)key freshness:(id)freshness completionHandler:(void (^)(V _Nullable, NSError * _Nullable))completionHandler __attribute__((swift_name("get(key:freshness:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)invalidateKey:(K)key completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("invalidate(key:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)invalidateAllWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("invalidateAll(completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)invalidateNamespaceNamespace:(Store6MutationsStore6_coreStoreNamespace *)namespace_ completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("invalidateNamespace(namespace:completionHandler:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)mutateKey:(K)key ref:(Store6MutationsMutatorRef *)ref args:(id)args completionHandler:(void (^)(NSString * _Nullable, NSError * _Nullable))completionHandler __attribute__((swift_name("mutate(key:ref:args:completionHandler:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)pendingKey:(K)key completionHandler:(void (^)(NSArray * _Nullable, NSError * _Nullable))completionHandler __attribute__((swift_name("pending(key:completionHandler:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)pendingWritesWithCompletionHandler:(void (^)(NSArray * _Nullable, NSError * _Nullable))completionHandler __attribute__((swift_name("pendingWrites(completionHandler:)"))); +- (id)streamKey:(K)key freshness:(id)freshness __attribute__((swift_name("stream(key:freshness:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) id events __attribute__((swift_name("events"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) id keyEvents __attribute__((swift_name("keyEvents"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) id poisoned __attribute__((swift_name("poisoned"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationStoreBuilder"))) +@interface Store6MutationsMutationStoreBuilder : Store6MutationsBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)bookkeeperBookkeeper:(id)bookkeeper __attribute__((swift_name("bookkeeper(bookkeeper:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)conflictsConfigure:(void (^)(Store6MutationsMutationConflictBuilder *))configure __attribute__((swift_name("conflicts(configure:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)fetcherFetch:(id)fetch __attribute__((swift_name("fetcher(fetch:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)fetcherFetcher:(id)fetcher __attribute__((swift_name("fetcher(fetcher:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)fetcherOfResultFetch:(id)fetch __attribute__((swift_name("fetcherOfResult(fetch:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)freshnessValidatorValidator:(id)validator __attribute__((swift_name("freshnessValidator(validator:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)journalStorageStorage:(id)storage __attribute__((swift_name("journalStorage(storage:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)maxIdleKeysCount:(int32_t)count __attribute__((swift_name("maxIdleKeys(count:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)persistenceSot:(id)sot __attribute__((swift_name("persistence(sot:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)telemetryTelemetry:(id)telemetry __attribute__((swift_name("telemetry(telemetry:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)wallClockWallClock:(id)wallClock __attribute__((swift_name("wallClock(wallClock:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutatorRef"))) +@interface Store6MutationsMutatorRef : Store6MutationsBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *id __attribute__((swift_name("id"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutatorRegistry"))) +@interface Store6MutationsMutatorRegistry : Store6MutationsBase +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutatorRegistryBuilder"))) +@interface Store6MutationsMutatorRegistryBuilder : Store6MutationsBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (Store6MutationsMutatorRef *)createId:(NSString *)id version:(int32_t)version codec:(id)codec stales:(Store6MutationsStaleSet *(^)(K, id))stales project:(V (^)(id))project __attribute__((swift_name("create(id:version:codec:stales:project:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (Store6MutationsMutatorRef *)deleteId:(NSString *)id stales:(Store6MutationsStaleSet *(^)(K, Store6MutationsKotlinUnit *))stales __attribute__((swift_name("delete(id:stales:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (Store6MutationsMutatorRef *)mutatorId:(NSString *)id version:(int32_t)version codec:(id)codec stales:(Store6MutationsStaleSet *(^)(K, id))stales project:(id _Nullable (^)(id, id))project __attribute__((swift_name("mutator(id:version:codec:stales:project:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (Store6MutationsMutatorRef *)updateId:(NSString *)id version:(int32_t)version codec:(id)codec stales:(Store6MutationsStaleSet *(^)(K, id))stales project:(V (^)(V, id))project __attribute__((swift_name("update(id:version:codec:stales:project:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (Store6MutationsMutatorRef *)upsertId:(NSString *)id version:(int32_t)version codec:(id)codec stales:(Store6MutationsStaleSet *(^)(K, id))stales project:(id (^)(id, id))project __attribute__((swift_name("upsert(id:version:codec:stales:project:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("PendingIntent"))) +@interface Store6MutationsPendingIntent : Store6MutationsBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int32_t attempt __attribute__((swift_name("attempt"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *canonicalId __attribute__((swift_name("canonicalId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t createdAtEpochMillis __attribute__((swift_name("createdAtEpochMillis"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *mutationId __attribute__((swift_name("mutationId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *mutatorId __attribute__((swift_name("mutatorId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly, getter=namespace) NSString *namespace_ __attribute__((swift_name("namespace_"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsMutationPendingState *state __attribute__((swift_name("state"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("PoisonedIntent"))) +@interface Store6MutationsPoisonedIntent : Store6MutationsBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsKotlinThrowable *failure __attribute__((swift_name("failure"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *mutationId __attribute__((swift_name("mutationId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *mutatorId __attribute__((swift_name("mutatorId"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StaleSet"))) +@interface Store6MutationsStaleSet : Store6MutationsBase +- (instancetype)initWithKeys:(NSSet *)keys namespaces:(NSSet *)namespaces __attribute__((swift_name("init(keys:namespaces:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSSet *keys __attribute__((swift_name("keys"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSSet *namespaces __attribute__((swift_name("namespaces"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("MutationJournalStorage"))) +@protocol Store6MutationsMutationJournalStorage +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)transactionBlock:(id _Nullable (^)(id))block completionHandler:(void (^)(id _Nullable_result, NSError * _Nullable))completionHandler __attribute__((swift_name("transaction(block:completionHandler:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("InMemoryMutationJournalStorage"))) +@interface Store6MutationsInMemoryMutationJournalStorage : Store6MutationsBase +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); ++ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)transactionBlock:(id _Nullable (^)(id))block completionHandler:(void (^)(id _Nullable_result, NSError * _Nullable))completionHandler __attribute__((swift_name("transaction(block:completionHandler:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationAckRecord"))) +@interface Store6MutationsMutationAckRecord : Store6MutationsBase +- (instancetype)initWithClientId:(NSString *)clientId clientSequence:(int64_t)clientSequence generation:(int32_t)generation authoritativePresence:(Store6MutationsMutationPresenceState *)authoritativePresence authoritativeBlob:(Store6MutationsKotlinByteArray * _Nullable)authoritativeBlob valueCodecVersion:(int32_t)valueCodecVersion etag:(NSString * _Nullable)etag canonicalTargetNamespace:(NSString * _Nullable)canonicalTargetNamespace canonicalTargetId:(NSString * _Nullable)canonicalTargetId receivedAt:(int64_t)receivedAt __attribute__((swift_name("init(clientId:clientSequence:generation:authoritativePresence:authoritativeBlob:valueCodecVersion:etag:canonicalTargetNamespace:canonicalTargetId:receivedAt:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsKotlinByteArray * _Nullable authoritativeBlob __attribute__((swift_name("authoritativeBlob"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsMutationPresenceState *authoritativePresence __attribute__((swift_name("authoritativePresence"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString * _Nullable canonicalTargetId __attribute__((swift_name("canonicalTargetId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString * _Nullable canonicalTargetNamespace __attribute__((swift_name("canonicalTargetNamespace"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *clientId __attribute__((swift_name("clientId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t clientSequence __attribute__((swift_name("clientSequence"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString * _Nullable etag __attribute__((swift_name("etag"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int32_t generation __attribute__((swift_name("generation"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t receivedAt __attribute__((swift_name("receivedAt"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int32_t valueCodecVersion __attribute__((swift_name("valueCodecVersion"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationAliasState"))) +@interface Store6MutationsMutationAliasState : Store6MutationsKotlinEnum ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); +- (instancetype)initWithName:(NSString *)name ordinal:(int32_t)ordinal __attribute__((swift_name("init(name:ordinal:)"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable)); +@property (class, readonly) Store6MutationsMutationAliasState *pending __attribute__((swift_name("pending"))); +@property (class, readonly) Store6MutationsMutationAliasState *active __attribute__((swift_name("active"))); ++ (Store6MutationsKotlinArray *)values __attribute__((swift_name("values()"))); +@property (class, readonly) NSArray *entries __attribute__((swift_name("entries"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationAttemptRecord"))) +@interface Store6MutationsMutationAttemptRecord : Store6MutationsBase +- (instancetype)initWithClientId:(NSString *)clientId clientSequence:(int64_t)clientSequence generation:(int32_t)generation effectiveNamespace:(NSString *)effectiveNamespace effectiveCanonicalId:(NSString *)effectiveCanonicalId valueCodecVersion:(int32_t)valueCodecVersion basePresence:(Store6MutationsMutationPresenceState *)basePresence baseBlob:(Store6MutationsKotlinByteArray * _Nullable)baseBlob minePresence:(Store6MutationsMutationPresenceState *)minePresence mineBlob:(Store6MutationsKotlinByteArray * _Nullable)mineBlob preconditionMetaPresent:(BOOL)preconditionMetaPresent preconditionWrittenAt:(Store6MutationsLong * _Nullable)preconditionWrittenAt preconditionEtag:(NSString * _Nullable)preconditionEtag advertisedRetiredThroughSequence:(int64_t)advertisedRetiredThroughSequence generationIdempotencyKey:(NSString *)generationIdempotencyKey preparedAt:(int64_t)preparedAt conflictMetaPresent:(Store6MutationsBoolean * _Nullable)conflictMetaPresent conflictWrittenAt:(Store6MutationsLong * _Nullable)conflictWrittenAt conflictEtag:(NSString * _Nullable)conflictEtag conflictReceivedAt:(Store6MutationsLong * _Nullable)conflictReceivedAt __attribute__((swift_name("init(clientId:clientSequence:generation:effectiveNamespace:effectiveCanonicalId:valueCodecVersion:basePresence:baseBlob:minePresence:mineBlob:preconditionMetaPresent:preconditionWrittenAt:preconditionEtag:advertisedRetiredThroughSequence:generationIdempotencyKey:preparedAt:conflictMetaPresent:conflictWrittenAt:conflictEtag:conflictReceivedAt:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t advertisedRetiredThroughSequence __attribute__((swift_name("advertisedRetiredThroughSequence"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsKotlinByteArray * _Nullable baseBlob __attribute__((swift_name("baseBlob"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsMutationPresenceState *basePresence __attribute__((swift_name("basePresence"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *clientId __attribute__((swift_name("clientId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t clientSequence __attribute__((swift_name("clientSequence"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString * _Nullable conflictEtag __attribute__((swift_name("conflictEtag"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsBoolean * _Nullable conflictMetaPresent __attribute__((swift_name("conflictMetaPresent"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsLong * _Nullable conflictReceivedAt __attribute__((swift_name("conflictReceivedAt"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsLong * _Nullable conflictWrittenAt __attribute__((swift_name("conflictWrittenAt"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *effectiveCanonicalId __attribute__((swift_name("effectiveCanonicalId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *effectiveNamespace __attribute__((swift_name("effectiveNamespace"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int32_t generation __attribute__((swift_name("generation"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *generationIdempotencyKey __attribute__((swift_name("generationIdempotencyKey"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsKotlinByteArray * _Nullable mineBlob __attribute__((swift_name("mineBlob"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsMutationPresenceState *minePresence __attribute__((swift_name("minePresence"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString * _Nullable preconditionEtag __attribute__((swift_name("preconditionEtag"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) BOOL preconditionMetaPresent __attribute__((swift_name("preconditionMetaPresent"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsLong * _Nullable preconditionWrittenAt __attribute__((swift_name("preconditionWrittenAt"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t preparedAt __attribute__((swift_name("preparedAt"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int32_t valueCodecVersion __attribute__((swift_name("valueCodecVersion"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationClientRecord"))) +@interface Store6MutationsMutationClientRecord : Store6MutationsBase +- (instancetype)initWithRecordVersion:(int32_t)recordVersion clientId:(NSString *)clientId lastAllocatedSequence:(int64_t)lastAllocatedSequence retiredThroughSequence:(int64_t)retiredThroughSequence serverConfirmedRetiredThroughSequence:(int64_t)serverConfirmedRetiredThroughSequence createdAt:(int64_t)createdAt __attribute__((swift_name("init(recordVersion:clientId:lastAllocatedSequence:retiredThroughSequence:serverConfirmedRetiredThroughSequence:createdAt:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *clientId __attribute__((swift_name("clientId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t createdAt __attribute__((swift_name("createdAt"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t lastAllocatedSequence __attribute__((swift_name("lastAllocatedSequence"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int32_t recordVersion __attribute__((swift_name("recordVersion"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t retiredThroughSequence __attribute__((swift_name("retiredThroughSequence"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t serverConfirmedRetiredThroughSequence __attribute__((swift_name("serverConfirmedRetiredThroughSequence"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationEffectDisposition"))) +@interface Store6MutationsMutationEffectDisposition : Store6MutationsKotlinEnum ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); +- (instancetype)initWithName:(NSString *)name ordinal:(int32_t)ordinal __attribute__((swift_name("init(name:ordinal:)"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable)); +@property (class, readonly) Store6MutationsMutationEffectDisposition *pending __attribute__((swift_name("pending"))); +@property (class, readonly) Store6MutationsMutationEffectDisposition *applied __attribute__((swift_name("applied"))); +@property (class, readonly) Store6MutationsMutationEffectDisposition *skipped __attribute__((swift_name("skipped"))); ++ (Store6MutationsKotlinArray *)values __attribute__((swift_name("values()"))); +@property (class, readonly) NSArray *entries __attribute__((swift_name("entries"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationEffectKind"))) +@interface Store6MutationsMutationEffectKind : Store6MutationsKotlinEnum ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); +- (instancetype)initWithName:(NSString *)name ordinal:(int32_t)ordinal __attribute__((swift_name("init(name:ordinal:)"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable)); +@property (class, readonly) Store6MutationsMutationEffectKind *key __attribute__((swift_name("key"))); +@property (class, readonly) Store6MutationsMutationEffectKind *namespace_ __attribute__((swift_name("namespace_"))); ++ (Store6MutationsKotlinArray *)values __attribute__((swift_name("values()"))); +@property (class, readonly) NSArray *entries __attribute__((swift_name("entries"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationEffectRecord"))) +@interface Store6MutationsMutationEffectRecord : Store6MutationsBase +- (instancetype)initWithClientId:(NSString *)clientId clientSequence:(int64_t)clientSequence effectIndex:(int32_t)effectIndex kind:(Store6MutationsMutationEffectKind *)kind namespace:(NSString *)namespace_ canonicalId:(NSString * _Nullable)canonicalId createdAt:(int64_t)createdAt disposition:(Store6MutationsMutationEffectDisposition *)disposition completedAt:(Store6MutationsLong * _Nullable)completedAt __attribute__((swift_name("init(clientId:clientSequence:effectIndex:kind:namespace:canonicalId:createdAt:disposition:completedAt:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString * _Nullable canonicalId __attribute__((swift_name("canonicalId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *clientId __attribute__((swift_name("clientId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t clientSequence __attribute__((swift_name("clientSequence"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsLong * _Nullable completedAt __attribute__((swift_name("completedAt"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t createdAt __attribute__((swift_name("createdAt"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsMutationEffectDisposition *disposition __attribute__((swift_name("disposition"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int32_t effectIndex __attribute__((swift_name("effectIndex"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsMutationEffectKind *kind __attribute__((swift_name("kind"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly, getter=namespace) NSString *namespace_ __attribute__((swift_name("namespace_"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationExecutionPhase"))) +@interface Store6MutationsMutationExecutionPhase : Store6MutationsKotlinEnum ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); +- (instancetype)initWithName:(NSString *)name ordinal:(int32_t)ordinal __attribute__((swift_name("init(name:ordinal:)"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable)); +@property (class, readonly) Store6MutationsMutationExecutionPhase *unprepared __attribute__((swift_name("unprepared"))); +@property (class, readonly) Store6MutationsMutationExecutionPhase *ready __attribute__((swift_name("ready"))); +@property (class, readonly) Store6MutationsMutationExecutionPhase *inflight __attribute__((swift_name("inflight"))); +@property (class, readonly) Store6MutationsMutationExecutionPhase *refreshRequired __attribute__((swift_name("refreshRequired"))); +@property (class, readonly) Store6MutationsMutationExecutionPhase *acked __attribute__((swift_name("acked"))); +@property (class, readonly) Store6MutationsMutationExecutionPhase *effectsPending __attribute__((swift_name("effectsPending"))); +@property (class, readonly) Store6MutationsMutationExecutionPhase *parked __attribute__((swift_name("parked"))); +@property (class, readonly) Store6MutationsMutationExecutionPhase *retired __attribute__((swift_name("retired"))); ++ (Store6MutationsKotlinArray *)values __attribute__((swift_name("values()"))); +@property (class, readonly) NSArray *entries __attribute__((swift_name("entries"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationExecutionRecord"))) +@interface Store6MutationsMutationExecutionRecord : Store6MutationsBase +- (instancetype)initWithClientId:(NSString *)clientId clientSequence:(int64_t)clientSequence phase:(Store6MutationsMutationExecutionPhase *)phase currentGeneration:(int32_t)currentGeneration attempt:(int32_t)attempt lastAttemptAt:(Store6MutationsLong * _Nullable)lastAttemptAt activeFailureId:(Store6MutationsLong * _Nullable)activeFailureId retiredAt:(Store6MutationsLong * _Nullable)retiredAt __attribute__((swift_name("init(clientId:clientSequence:phase:currentGeneration:attempt:lastAttemptAt:activeFailureId:retiredAt:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsLong * _Nullable activeFailureId __attribute__((swift_name("activeFailureId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int32_t attempt __attribute__((swift_name("attempt"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *clientId __attribute__((swift_name("clientId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t clientSequence __attribute__((swift_name("clientSequence"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int32_t currentGeneration __attribute__((swift_name("currentGeneration"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsLong * _Nullable lastAttemptAt __attribute__((swift_name("lastAttemptAt"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsMutationExecutionPhase *phase __attribute__((swift_name("phase"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsLong * _Nullable retiredAt __attribute__((swift_name("retiredAt"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationFailureRecord"))) +@interface Store6MutationsMutationFailureRecord : Store6MutationsBase +- (instancetype)initWithFailureId:(int64_t)failureId clientId:(NSString *)clientId clientSequence:(int64_t)clientSequence generation:(int32_t)generation kind:(Store6MutationsMutationFailureKind *)kind detail:(NSString *)detail message:(NSString *)message occurredAt:(int64_t)occurredAt __attribute__((swift_name("init(failureId:clientId:clientSequence:generation:kind:detail:message:occurredAt:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *clientId __attribute__((swift_name("clientId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t clientSequence __attribute__((swift_name("clientSequence"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *detail __attribute__((swift_name("detail"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t failureId __attribute__((swift_name("failureId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int32_t generation __attribute__((swift_name("generation"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsMutationFailureKind *kind __attribute__((swift_name("kind"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *message __attribute__((swift_name("message"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t occurredAt __attribute__((swift_name("occurredAt"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationIntentRecord"))) +@interface Store6MutationsMutationIntentRecord : Store6MutationsBase +- (instancetype)initWithRowId:(int64_t)rowId recordVersion:(int32_t)recordVersion clientId:(NSString *)clientId clientSequence:(int64_t)clientSequence mutationId:(NSString *)mutationId namespace:(NSString *)namespace_ canonicalId:(NSString *)canonicalId mutatorId:(NSString *)mutatorId mutatorVersion:(int32_t)mutatorVersion argsBlob:(Store6MutationsKotlinByteArray *)argsBlob idempotencyRoot:(NSString *)idempotencyRoot createdAt:(int64_t)createdAt __attribute__((swift_name("init(rowId:recordVersion:clientId:clientSequence:mutationId:namespace:canonicalId:mutatorId:mutatorVersion:argsBlob:idempotencyRoot:createdAt:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsKotlinByteArray *argsBlob __attribute__((swift_name("argsBlob"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *canonicalId __attribute__((swift_name("canonicalId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *clientId __attribute__((swift_name("clientId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t clientSequence __attribute__((swift_name("clientSequence"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t createdAt __attribute__((swift_name("createdAt"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *idempotencyRoot __attribute__((swift_name("idempotencyRoot"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *mutationId __attribute__((swift_name("mutationId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *mutatorId __attribute__((swift_name("mutatorId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int32_t mutatorVersion __attribute__((swift_name("mutatorVersion"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly, getter=namespace) NSString *namespace_ __attribute__((swift_name("namespace_"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int32_t recordVersion __attribute__((swift_name("recordVersion"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t rowId __attribute__((swift_name("rowId"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("MutationJournalTransaction"))) +@protocol Store6MutationsMutationJournalTransaction +@required +- (NSArray *)acksClientId:(NSString *)clientId __attribute__((swift_name("acks(clientId:)"))); +- (void)advanceAliasRecord:(Store6MutationsMutationKeyAliasRecord *)record __attribute__((swift_name("advanceAlias(record:)"))); +- (void)advanceClientRecord:(Store6MutationsMutationClientRecord *)record __attribute__((swift_name("advanceClient(record:)"))); +- (void)advanceEffectRecord:(Store6MutationsMutationEffectRecord *)record __attribute__((swift_name("advanceEffect(record:)"))); +- (void)advanceExecutionRecord:(Store6MutationsMutationExecutionRecord *)record __attribute__((swift_name("advanceExecution(record:)"))); +- (void)advanceTombstoneRecord:(Store6MutationsMutationKeyTombstoneRecord *)record __attribute__((swift_name("advanceTombstone(record:)"))); +- (NSArray *)aliases __attribute__((swift_name("aliases()"))); +- (Store6MutationsMutationFailureRecord *)appendFailureClientId:(NSString *)clientId clientSequence:(int64_t)clientSequence generation:(int32_t)generation kind:(Store6MutationsMutationFailureKind *)kind detail:(NSString *)detail message:(NSString *)message occurredAt:(int64_t)occurredAt __attribute__((swift_name("appendFailure(clientId:clientSequence:generation:kind:detail:message:occurredAt:)"))); +- (NSArray *)attemptsClientId:(NSString *)clientId __attribute__((swift_name("attempts(clientId:)"))); +- (Store6MutationsMutationClientRecord * _Nullable)clientClientId:(NSString *)clientId __attribute__((swift_name("client(clientId:)"))); +- (Store6MutationsMutationClientRecord *)confirmRetiredThroughClientId:(NSString *)clientId requestedThroughSequence:(int64_t)requestedThroughSequence serverConfirmedThroughSequence:(int64_t)serverConfirmedThroughSequence __attribute__((swift_name("confirmRetiredThrough(clientId:requestedThroughSequence:serverConfirmedThroughSequence:)"))); +- (NSArray *)effectsClientId:(NSString *)clientId __attribute__((swift_name("effects(clientId:)"))); +- (NSArray *)executionsClientId:(NSString *)clientId __attribute__((swift_name("executions(clientId:)"))); +- (NSArray *)failuresClientId:(NSString *)clientId __attribute__((swift_name("failures(clientId:)"))); +- (void)insertAckRecord:(Store6MutationsMutationAckRecord *)record __attribute__((swift_name("insertAck(record:)"))); +- (void)insertAliasRecord:(Store6MutationsMutationKeyAliasRecord *)record __attribute__((swift_name("insertAlias(record:)"))); +- (void)insertAttemptRecord:(Store6MutationsMutationAttemptRecord *)record __attribute__((swift_name("insertAttempt(record:)"))); +- (void)insertClientRecord:(Store6MutationsMutationClientRecord *)record __attribute__((swift_name("insertClient(record:)"))); +- (void)insertEffectRecord:(Store6MutationsMutationEffectRecord *)record __attribute__((swift_name("insertEffect(record:)"))); +- (void)insertExecutionRecord:(Store6MutationsMutationExecutionRecord *)record __attribute__((swift_name("insertExecution(record:)"))); +- (Store6MutationsMutationIntentRecord *)insertIntentRecordVersion:(int32_t)recordVersion clientId:(NSString *)clientId clientSequence:(int64_t)clientSequence mutationId:(NSString *)mutationId namespace:(NSString *)namespace_ canonicalId:(NSString *)canonicalId mutatorId:(NSString *)mutatorId mutatorVersion:(int32_t)mutatorVersion argsBlob:(Store6MutationsKotlinByteArray *)argsBlob idempotencyRoot:(NSString *)idempotencyRoot createdAt:(int64_t)createdAt __attribute__((swift_name("insertIntent(recordVersion:clientId:clientSequence:mutationId:namespace:canonicalId:mutatorId:mutatorVersion:argsBlob:idempotencyRoot:createdAt:)"))); +- (void)insertTombstoneRecord:(Store6MutationsMutationKeyTombstoneRecord *)record __attribute__((swift_name("insertTombstone(record:)"))); +- (NSArray *)intentsClientId:(NSString *)clientId __attribute__((swift_name("intents(clientId:)"))); +- (void)pruneClientId:(NSString *)clientId serverConfirmedRetiredThroughSequence:(int64_t)serverConfirmedRetiredThroughSequence __attribute__((swift_name("prune(clientId:serverConfirmedRetiredThroughSequence:)"))); +- (void)recordConflictReceiptRecord:(Store6MutationsMutationAttemptRecord *)record __attribute__((swift_name("recordConflictReceipt(record:)"))); +- (NSArray *)tombstones __attribute__((swift_name("tombstones()"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationKeyAliasRecord"))) +@interface Store6MutationsMutationKeyAliasRecord : Store6MutationsBase +- (instancetype)initWithSourceNamespace:(NSString *)sourceNamespace sourceCanonicalId:(NSString *)sourceCanonicalId targetNamespace:(NSString *)targetNamespace targetCanonicalId:(NSString *)targetCanonicalId state:(Store6MutationsMutationAliasState *)state createdByClientId:(NSString *)createdByClientId createdBySequence:(int64_t)createdBySequence createdAt:(int64_t)createdAt activatedAt:(Store6MutationsLong * _Nullable)activatedAt __attribute__((swift_name("init(sourceNamespace:sourceCanonicalId:targetNamespace:targetCanonicalId:state:createdByClientId:createdBySequence:createdAt:activatedAt:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsLong * _Nullable activatedAt __attribute__((swift_name("activatedAt"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t createdAt __attribute__((swift_name("createdAt"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *createdByClientId __attribute__((swift_name("createdByClientId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t createdBySequence __attribute__((swift_name("createdBySequence"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *sourceCanonicalId __attribute__((swift_name("sourceCanonicalId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *sourceNamespace __attribute__((swift_name("sourceNamespace"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsMutationAliasState *state __attribute__((swift_name("state"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *targetCanonicalId __attribute__((swift_name("targetCanonicalId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *targetNamespace __attribute__((swift_name("targetNamespace"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationKeyTombstoneRecord"))) +@interface Store6MutationsMutationKeyTombstoneRecord : Store6MutationsBase +- (instancetype)initWithNamespace:(NSString *)namespace_ canonicalId:(NSString *)canonicalId createdByClientId:(NSString *)createdByClientId createdBySequence:(int64_t)createdBySequence state:(Store6MutationsMutationTombstoneState *)state createdAt:(int64_t)createdAt activatedAt:(Store6MutationsLong * _Nullable)activatedAt supersededByClientId:(NSString * _Nullable)supersededByClientId supersededBySequence:(Store6MutationsLong * _Nullable)supersededBySequence supersededAt:(Store6MutationsLong * _Nullable)supersededAt __attribute__((swift_name("init(namespace:canonicalId:createdByClientId:createdBySequence:state:createdAt:activatedAt:supersededByClientId:supersededBySequence:supersededAt:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsLong * _Nullable activatedAt __attribute__((swift_name("activatedAt"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *canonicalId __attribute__((swift_name("canonicalId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t createdAt __attribute__((swift_name("createdAt"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *createdByClientId __attribute__((swift_name("createdByClientId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t createdBySequence __attribute__((swift_name("createdBySequence"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly, getter=namespace) NSString *namespace_ __attribute__((swift_name("namespace_"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsMutationTombstoneState *state __attribute__((swift_name("state"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsLong * _Nullable supersededAt __attribute__((swift_name("supersededAt"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString * _Nullable supersededByClientId __attribute__((swift_name("supersededByClientId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) Store6MutationsLong * _Nullable supersededBySequence __attribute__((swift_name("supersededBySequence"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationTombstoneState"))) +@interface Store6MutationsMutationTombstoneState : Store6MutationsKotlinEnum ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); +- (instancetype)initWithName:(NSString *)name ordinal:(int32_t)ordinal __attribute__((swift_name("init(name:ordinal:)"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable)); +@property (class, readonly) Store6MutationsMutationTombstoneState *pending __attribute__((swift_name("pending"))); +@property (class, readonly) Store6MutationsMutationTombstoneState *active __attribute__((swift_name("active"))); +@property (class, readonly) Store6MutationsMutationTombstoneState *superseded __attribute__((swift_name("superseded"))); ++ (Store6MutationsKotlinArray *)values __attribute__((swift_name("values()"))); +@property (class, readonly) NSArray *entries __attribute__((swift_name("entries"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationStoreKt"))) +@interface Store6MutationsMutationStoreKt : Store6MutationsBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ ++ (Store6MutationsMutationStore, id> *)mutationStoreRegistry:(Store6MutationsMutatorRegistry, id> *)registry server:(id)server keyResolver:(id)keyResolver valueCodecVersion:(int32_t)valueCodecVersion valueCodec:(id)valueCodec configure:(void (^)(Store6MutationsMutationStoreBuilder, id> *))configure __attribute__((swift_name("mutationStore(registry:server:keyResolver:valueCodecVersion:valueCodec:configure:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutatorRegistryKt"))) +@interface Store6MutationsMutatorRegistryKt : Store6MutationsBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ ++ (Store6MutationsMutatorRegistry, id> *)mutatorRegistryConfigure:(void (^)(Store6MutationsMutatorRegistryBuilder, id> *))configure __attribute__((swift_name("mutatorRegistry(configure:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("KotlinByteArray"))) +@interface Store6MutationsKotlinByteArray : Store6MutationsBase ++ (instancetype)arrayWithSize:(int32_t)size __attribute__((swift_name("init(size:)"))); ++ (instancetype)arrayWithSize:(int32_t)size init:(Store6MutationsByte *(^)(Store6MutationsInt *))init __attribute__((swift_name("init(size:init:)"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); +- (int8_t)getIndex:(int32_t)index __attribute__((swift_name("get(index:)"))); +- (Store6MutationsKotlinByteIterator *)iterator __attribute__((swift_name("iterator()"))); +- (void)setIndex:(int32_t)index value:(int8_t)value __attribute__((swift_name("set(index:value:)"))); +@property (readonly) int32_t size __attribute__((swift_name("size"))); +@end + +__attribute__((swift_name("Store6_coreStoreMeta"))) +@protocol Store6MutationsStore6_coreStoreMeta +@required +@property (readonly) NSString * _Nullable etag __attribute__((swift_name("etag"))); +@property (readonly) int64_t writtenAtEpochMillis __attribute__((swift_name("writtenAtEpochMillis"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("KotlinEnumCompanion"))) +@interface Store6MutationsKotlinEnumCompanion : Store6MutationsBase ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)companion __attribute__((swift_name("init()"))); +@property (class, readonly, getter=shared) Store6MutationsKotlinEnumCompanion *shared __attribute__((swift_name("shared"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("KotlinArray"))) +@interface Store6MutationsKotlinArray : Store6MutationsBase ++ (instancetype)arrayWithSize:(int32_t)size init:(T _Nullable (^)(Store6MutationsInt *))init __attribute__((swift_name("init(size:init:)"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); +- (T _Nullable)getIndex:(int32_t)index __attribute__((swift_name("get(index:)"))); +- (id)iterator __attribute__((swift_name("iterator()"))); +- (void)setIndex:(int32_t)index value:(T _Nullable)value __attribute__((swift_name("set(index:value:)"))); +@property (readonly) int32_t size __attribute__((swift_name("size"))); +@end + +__attribute__((swift_name("KotlinThrowable"))) +@interface Store6MutationsKotlinThrowable : Store6MutationsBase +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); ++ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); +- (instancetype)initWithMessage:(NSString * _Nullable)message __attribute__((swift_name("init(message:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithCause:(Store6MutationsKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithMessage:(NSString * _Nullable)message cause:(Store6MutationsKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(message:cause:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note annotations + * kotlin.experimental.ExperimentalNativeApi +*/ +- (Store6MutationsKotlinArray *)getStackTrace __attribute__((swift_name("getStackTrace()"))); +- (void)printStackTrace __attribute__((swift_name("printStackTrace()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@property (readonly) Store6MutationsKotlinThrowable * _Nullable cause __attribute__((swift_name("cause"))); +@property (readonly) NSString * _Nullable message __attribute__((swift_name("message"))); +- (NSError *)asError __attribute__((swift_name("asError()"))); +@end + +__attribute__((swift_name("KotlinException"))) +@interface Store6MutationsKotlinException : Store6MutationsKotlinThrowable +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); ++ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); +- (instancetype)initWithMessage:(NSString * _Nullable)message __attribute__((swift_name("init(message:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithCause:(Store6MutationsKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithMessage:(NSString * _Nullable)message cause:(Store6MutationsKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(message:cause:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((swift_name("KotlinRuntimeException"))) +@interface Store6MutationsKotlinRuntimeException : Store6MutationsKotlinException +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); ++ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); +- (instancetype)initWithMessage:(NSString * _Nullable)message __attribute__((swift_name("init(message:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithCause:(Store6MutationsKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithMessage:(NSString * _Nullable)message cause:(Store6MutationsKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(message:cause:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((swift_name("KotlinIllegalStateException"))) +@interface Store6MutationsKotlinIllegalStateException : Store6MutationsKotlinRuntimeException +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); ++ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); +- (instancetype)initWithMessage:(NSString * _Nullable)message __attribute__((swift_name("init(message:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithCause:(Store6MutationsKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithMessage:(NSString * _Nullable)message cause:(Store6MutationsKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(message:cause:)"))) __attribute__((objc_designated_initializer)); +@end + + +/** + * @note annotations + * kotlin.SinceKotlin(version="1.4") +*/ +__attribute__((swift_name("KotlinCancellationException"))) +@interface Store6MutationsKotlinCancellationException : Store6MutationsKotlinIllegalStateException +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); ++ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); +- (instancetype)initWithMessage:(NSString * _Nullable)message __attribute__((swift_name("init(message:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithCause:(Store6MutationsKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithMessage:(NSString * _Nullable)message cause:(Store6MutationsKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(message:cause:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((swift_name("Store6_coreStoreKey"))) +@protocol Store6MutationsStore6_coreStoreKey +@required +- (NSString *)canonicalId __attribute__((swift_name("canonicalId()"))); +@property (readonly, getter=namespace) Store6MutationsStore6_coreStoreNamespace *namespace_ __attribute__((swift_name("namespace_"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Store6_coreStoreNamespace"))) +@interface Store6MutationsStore6_coreStoreNamespace : Store6MutationsBase +- (instancetype)initWithValue:(NSString *)value __attribute__((swift_name("init(value:)"))) __attribute__((objc_designated_initializer)); +@property (readonly) NSString *value __attribute__((swift_name("value"))); +@end + +__attribute__((swift_name("Store6_coreFreshness"))) +@protocol Store6MutationsStore6_coreFreshness +@required +@end + +__attribute__((swift_name("Kotlinx_coroutines_coreFlow"))) +@protocol Store6MutationsKotlinx_coroutines_coreFlow +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)collectCollector:(id)collector completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("collect(collector:completionHandler:)"))); +@end + +__attribute__((swift_name("Kotlinx_coroutines_coreSharedFlow"))) +@protocol Store6MutationsKotlinx_coroutines_coreSharedFlow +@required +@property (readonly) NSArray *replayCache __attribute__((swift_name("replayCache"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("Store6_coreBookkeeper"))) +@protocol Store6MutationsStore6_coreBookkeeper +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)advanceGlobalStaleWatermarkWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("advanceGlobalStaleWatermark(completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)advanceStaleWatermarkNamespace:(Store6MutationsStore6_coreStoreNamespace *)namespace_ completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("advanceStaleWatermark(namespace:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)forgetKey:(id)key completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("forget(key:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)forgetAllWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("forgetAll(completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)forgetNamespaceNamespace:(Store6MutationsStore6_coreStoreNamespace *)namespace_ completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("forgetNamespace(namespace:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)markStaleKey:(id)key completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("markStale(key:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)recordFailureKey:(id)key atEpochMillis:(int64_t)atEpochMillis completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("recordFailure(key:atEpochMillis:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)recordSuccessKey:(id)key meta:(id)meta completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("recordSuccess(key:meta:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)statusKey:(id)key completionHandler:(void (^)(Store6MutationsStore6_coreKeyStatus * _Nullable_result, NSError * _Nullable))completionHandler __attribute__((swift_name("status(key:completionHandler:)"))); +@end + +__attribute__((swift_name("KotlinFunction"))) +@protocol Store6MutationsKotlinFunction +@required +@end + +__attribute__((swift_name("KotlinSuspendFunction1"))) +@protocol Store6MutationsKotlinSuspendFunction1 +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)invokeP1:(id _Nullable)p1 completionHandler:(void (^)(id _Nullable_result, NSError * _Nullable))completionHandler __attribute__((swift_name("invoke(p1:completionHandler:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("Store6_coreFetcher"))) +@protocol Store6MutationsStore6_coreFetcher +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)fetchKey:(id)key etag:(NSString * _Nullable)etag completionHandler:(void (^)(id _Nullable, NSError * _Nullable))completionHandler __attribute__((swift_name("fetch(key:etag:completionHandler:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("Store6_coreFreshnessValidator"))) +@protocol Store6MutationsStore6_coreFreshnessValidator +@required +- (id)planContext:(Store6MutationsStore6_coreFreshnessContext *)context __attribute__((swift_name("plan(context:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("Store6_coreSourceOfTruth"))) +@protocol Store6MutationsStore6_coreSourceOfTruth +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)deleteKey:(id)key completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("delete(key:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)deleteAllWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("deleteAll(completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)deleteNamespaceNamespace:(Store6MutationsStore6_coreStoreNamespace *)namespace_ completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("deleteNamespace(namespace:completionHandler:)"))); +- (id)readerKey:(id)key __attribute__((swift_name("reader(key:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)writeKey:(id)key value:(id)value completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("write(key:value:completionHandler:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("Store6_coreStoreTelemetry"))) +@protocol Store6MutationsStore6_coreStoreTelemetry +@required +- (void)onClearedKey:(id)key __attribute__((swift_name("onCleared(key:)"))); +- (void)onFetchFailedKey:(id)key error:(Store6MutationsStore6_coreStoreError *)error duration:(int64_t)duration __attribute__((swift_name("onFetchFailed(key:error:duration:)"))); +- (void)onFetchStartedKey:(id)key __attribute__((swift_name("onFetchStarted(key:)"))); +- (void)onFetchSucceededKey:(id)key duration:(int64_t)duration __attribute__((swift_name("onFetchSucceeded(key:duration:)"))); +- (void)onInvalidatedKey:(id)key __attribute__((swift_name("onInvalidated(key:)"))); +- (void)onServeKey:(id)key origin:(Store6MutationsStore6_coreOrigin *)origin __attribute__((swift_name("onServe(key:origin:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("Store6_coreWallClock"))) +@protocol Store6MutationsStore6_coreWallClock +@required +- (int64_t)nowEpochMillis __attribute__((swift_name("nowEpochMillis()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("KotlinUnit"))) +@interface Store6MutationsKotlinUnit : Store6MutationsBase ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)unit __attribute__((swift_name("init()"))); +@property (class, readonly, getter=shared) Store6MutationsKotlinUnit *shared __attribute__((swift_name("shared"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + +__attribute__((swift_name("KotlinIterator"))) +@protocol Store6MutationsKotlinIterator +@required +- (BOOL)hasNext __attribute__((swift_name("hasNext()"))); +- (id _Nullable)next __attribute__((swift_name("next()"))); +@end + +__attribute__((swift_name("KotlinByteIterator"))) +@interface Store6MutationsKotlinByteIterator : Store6MutationsBase +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); ++ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); +- (Store6MutationsByte *)next __attribute__((swift_name("next()"))); +- (int8_t)nextByte __attribute__((swift_name("nextByte()"))); +@end + +__attribute__((swift_name("Kotlinx_coroutines_coreFlowCollector"))) +@protocol Store6MutationsKotlinx_coroutines_coreFlowCollector +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)emitValue:(id _Nullable)value completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("emit(value:completionHandler:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Store6_coreKeyStatus"))) +@interface Store6MutationsStore6_coreKeyStatus : Store6MutationsBase +- (instancetype)initWithMeta:(id _Nullable)meta lastSuccessSequence:(Store6MutationsLong * _Nullable)lastSuccessSequence lastFailureAtEpochMillis:(Store6MutationsLong * _Nullable)lastFailureAtEpochMillis consecutiveFailures:(int32_t)consecutiveFailures durablyStale:(BOOL)durablyStale __attribute__((swift_name("init(meta:lastSuccessSequence:lastFailureAtEpochMillis:consecutiveFailures:durablyStale:)"))) __attribute__((objc_designated_initializer)); +@property (readonly) int32_t consecutiveFailures __attribute__((swift_name("consecutiveFailures"))); +@property (readonly) BOOL durablyStale __attribute__((swift_name("durablyStale"))); +@property (readonly) Store6MutationsLong * _Nullable lastFailureAtEpochMillis __attribute__((swift_name("lastFailureAtEpochMillis"))); +@property (readonly) Store6MutationsLong * _Nullable lastSuccessSequence __attribute__((swift_name("lastSuccessSequence"))); +@property (readonly) id _Nullable meta __attribute__((swift_name("meta"))); +@end + +__attribute__((swift_name("Store6_coreFetcherResult"))) +@protocol Store6MutationsStore6_coreFetcherResult +@required +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((swift_name("Store6_coreFetchPlan"))) +@protocol Store6MutationsStore6_coreFetchPlan +@required +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Store6_coreFreshnessContext"))) +@interface Store6MutationsStore6_coreFreshnessContext : Store6MutationsBase +- (instancetype)initWithHasResidentValue:(BOOL)hasResidentValue meta:(id _Nullable)meta epochStale:(BOOL)epochStale freshness:(id)freshness nowEpochMillis:(int64_t)nowEpochMillis status:(Store6MutationsStore6_coreKeyStatus * _Nullable)status __attribute__((swift_name("init(hasResidentValue:meta:epochStale:freshness:nowEpochMillis:status:)"))) __attribute__((objc_designated_initializer)); +@property (readonly) BOOL epochStale __attribute__((swift_name("epochStale"))); +@property (readonly) id freshness __attribute__((swift_name("freshness"))); +@property (readonly) BOOL hasResidentValue __attribute__((swift_name("hasResidentValue"))); +@property (readonly) id _Nullable meta __attribute__((swift_name("meta"))); +@property (readonly) int64_t nowEpochMillis __attribute__((swift_name("nowEpochMillis"))); +@property (readonly) Store6MutationsStore6_coreKeyStatus * _Nullable status __attribute__((swift_name("status"))); +@end + +__attribute__((swift_name("Store6_coreStoreError"))) +@interface Store6MutationsStore6_coreStoreError : Store6MutationsBase +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Store6_coreOrigin"))) +@interface Store6MutationsStore6_coreOrigin : Store6MutationsKotlinEnum ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); +- (instancetype)initWithName:(NSString *)name ordinal:(int32_t)ordinal __attribute__((swift_name("init(name:ordinal:)"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable)); +@property (class, readonly) Store6MutationsStore6_coreOrigin *memory __attribute__((swift_name("memory"))); +@property (class, readonly) Store6MutationsStore6_coreOrigin *sot __attribute__((swift_name("sot"))); +@property (class, readonly) Store6MutationsStore6_coreOrigin *fetcher __attribute__((swift_name("fetcher"))); +@property (class, readonly) Store6MutationsStore6_coreOrigin *overlay __attribute__((swift_name("overlay"))); ++ (Store6MutationsKotlinArray *)values __attribute__((swift_name("values()"))); +@property (class, readonly) NSArray *entries __attribute__((swift_name("entries"))); +@end + +#pragma pop_macro("_Nullable_result") +#pragma clang diagnostic pop +NS_ASSUME_NONNULL_END diff --git a/store6-mutations/api/swift/skie/Store6MutationsSkie.h b/store6-mutations/api/swift/skie/Store6MutationsSkie.h new file mode 100644 index 000000000..889b6fb33 --- /dev/null +++ b/store6-mutations/api/swift/skie/Store6MutationsSkie.h @@ -0,0 +1,3494 @@ +#import +#import +#import +#import +#import +#import +#import + +@class SMS__SkieTypeExportsKt, SMS__SkieSuspendWrappersKt, SMSUShort, SMSULong, SMSUInt, SMSUByte, SMSStore6_coreStoreResultRevalidated, SMSStore6_coreStoreResultLoading, SMSStore6_coreStoreResultError, SMSStore6_coreStoreResultData, SMSStore6_coreStoreNamespace, SMSStore6_coreStoreErrorPersistence, SMSStore6_coreStoreErrorMissing, SMSStore6_coreStoreErrorFreshnessUnsatisfiable, SMSStore6_coreStoreErrorFetch, SMSStore6_coreStoreErrorConversion, SMSStore6_coreStoreErrorConflict, SMSStore6_coreStoreError, SMSStore6_coreOrigin, SMSStore6_coreKeyStatus, SMSStore6_coreKeyEvents, SMSStore6_coreFreshnessStaleIfError, SMSStore6_coreFreshnessMustBeFresh, SMSStore6_coreFreshnessMaxAge, SMSStore6_coreFreshnessLocalOnly, SMSStore6_coreFreshnessContext, SMSStore6_coreFreshnessCachedOrFetch, SMSStore6_coreFetcherResultSuccess, SMSStore6_coreFetcherResultNotModified, SMSStore6_coreFetcherResultError, SMSStore6_coreFetcherResultDeleted, SMSStore6_coreFetchPlanSkip, SMSStore6_coreFetchPlanFetch, SMSStore6_coreFetchPlanConditional, SMSStaleSet, SMSSkie_SuspendResultSuccess, SMSSkie_SuspendResultError, SMSSkie_SuspendResultCanceled, SMSSkie_SuspendResult, SMSSkie_SuspendHandler, SMSSkie_CancellationHandler, SMSSkieKotlinStateFlow, SMSSkieKotlinSharedFlow, SMSSkieKotlinOptionalStateFlow, SMSSkieKotlinOptionalSharedFlow, SMSSkieKotlinOptionalMutableStateFlow, SMSSkieKotlinOptionalMutableSharedFlow, SMSSkieKotlinOptionalFlow, SMSSkieKotlinMutableStateFlow, SMSSkieKotlinMutableSharedFlow, SMSSkieKotlinFlow, SMSSkieColdFlowIterator, SMSShort, SMSPoisonedIntent, SMSPendingIntent, SMSNumber, SMSMutatorRegistryKt, SMSMutatorRegistryBuilder, SMSMutatorRegistry, SMSMutatorRef, SMSMutationTombstoneState, SMSMutationStoreKt, SMSMutationStoreBuilder, SMSMutationStore, SMSMutationRetirementAck, SMSMutationRetirement, SMSMutationRetired, SMSMutationPush, SMSMutationPresentAck, SMSMutationPresenceState, SMSMutationPresencePresent, SMSMutationPresenceAbsent, SMSMutationPreconditionCandidate, SMSMutationPendingState, SMSMutationParked, SMSMutationKeyTombstoneRecord, SMSMutationKeyIdentity, SMSMutationKeyAliasRecord, SMSMutationIntentRecord, SMSMutationFailureRecord, SMSMutationFailureKind, SMSMutationFailure, SMSMutationFailed, SMSMutationExecutionRecord, SMSMutationExecutionPhase, SMSMutationEnqueued, SMSMutationEffectSkipped, SMSMutationEffectRecord, SMSMutationEffectKind, SMSMutationEffectDisposition, SMSMutationEffectApplied, SMSMutationConflictResolutionServerWins, SMSMutationConflictResolutionRetry, SMSMutationConflictObserved, SMSMutationConflictBuilder, SMSMutationClientRecord, SMSMutationCheckpointFailed, SMSMutationCheckpointConfirmed, SMSMutationAttempted, SMSMutationAttemptRecord, SMSMutationAliasState, SMSMutationAdopted, SMSMutationAcknowledged, SMSMutationAckRecord, SMSMutationAbsentAck, SMSMutableSet, SMSMutableDictionary, SMSLong, SMSKotlinUnit, SMSKotlinThrowable, SMSKotlinRuntimeException, SMSKotlinIllegalStateException, SMSKotlinException, SMSKotlinEnumCompanion, SMSKotlinEnum, SMSKotlinCancellationException, SMSKotlinByteIterator, SMSKotlinByteArray, SMSKotlinArray, SMSInt, SMSInMemoryMutationJournalStorage, SMSFloat, SMSDouble, SMSDeadLetter, SMSByte, SMSBoolean, SMSBase, NSString, NSSet, NSObject, NSNumber, NSMutableSet, NSMutableDictionary, NSMutableArray, NSError, NSDictionary, NSArray; + +@protocol SMSStore6_coreWallClock, SMSStore6_coreStoreTelemetry, SMSStore6_coreStoreResult, SMSStore6_coreStoreMeta, SMSStore6_coreStoreKey, SMSStore6_coreStore, SMSStore6_coreSourceOfTruth, SMSStore6_coreFreshnessValidator, SMSStore6_coreFreshness, SMSStore6_coreFetcherResult, SMSStore6_coreFetcher, SMSStore6_coreFetchPlan, SMSStore6_coreBookkeeper, SMSSkie_DispatcherDelegate, SMSMutationServer, SMSMutationPresence, SMSMutationKeyResolver, SMSMutationJournalTransaction, SMSMutationJournalStorage, SMSMutationIntentEvent, SMSMutationEvent, SMSMutationConflictResolution, SMSMutationCodec, SMSMutationAck, SMSKotlinx_coroutines_coreStateFlow, SMSKotlinx_coroutines_coreSharedFlow, SMSKotlinx_coroutines_coreRunnable, SMSKotlinx_coroutines_coreMutableStateFlow, SMSKotlinx_coroutines_coreMutableSharedFlow, SMSKotlinx_coroutines_coreFlowCollector, SMSKotlinx_coroutines_coreFlow, SMSKotlinSuspendFunction1, SMSKotlinIterator, SMSKotlinFunction, SMSKotlinComparable, NSCopying; + +// Due to an Obj-C/Swift interop limitation, SKIE cannot generate Swift types with a lambda type argument. +// Example of such type is: A<() -> Unit> where A is a generic class. +// To avoid compilation errors SKIE replaces these type arguments with __SkieLambdaErrorType, resulting in A<__SkieLambdaErrorType>. +// Generated declarations that reference __SkieLambdaErrorType cannot be called in any way and the __SkieLambdaErrorType class cannot be used. +// The original declarations can still be used in the same way as other declarations hidden by SKIE (and with the same limitations as without SKIE). +@interface __SkieLambdaErrorType : NSObject +- (instancetype _Nonnull)init __attribute__((unavailable)); ++ (instancetype _Nonnull)new __attribute__((unavailable)); +@end + +// Due to an Obj-C/Swift interop limitation, SKIE cannot generate Swift code that uses external Obj-C types for which SKIE doesn't know a fully qualified name. +// This problem occurs when custom Cinterop bindings are used because those do not contain the name of the Framework that provides implementation for those binding. +// The name can be configured manually using the SKIE Gradle configuration key 'ClassInterop.CInteropFrameworkName' in the same way as other SKIE features. +// To avoid compilation errors SKIE replaces types with unknown Framework name with __SkieUnknownCInteropFrameworkErrorType. +// Generated declarations that reference __SkieUnknownCInteropFrameworkErrorType cannot be called in any way and the __SkieUnknownCInteropFrameworkErrorType class cannot be used. +@interface __SkieUnknownCInteropFrameworkErrorType : NSObject +- (instancetype _Nonnull)init __attribute__((unavailable)); ++ (instancetype _Nonnull)new __attribute__((unavailable)); +@end + +typedef id _Nonnull Skie__TypeDef__0__id __attribute__((__swift_private__)); +typedef id _Nonnull Skie__TypeDef__1__id_SMSMutationCodec_ __attribute__((__swift_private__)); +typedef id _Nonnull Skie__TypeDef__2__id_SMSMutationPresence_ __attribute__((__swift_private__)); +typedef id _Nullable Skie__TypeDef__3__id_SMSMutationPresence___Nullable __attribute__((__swift_private__)); + +NS_ASSUME_NONNULL_BEGIN +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wunknown-warning-option" +#pragma clang diagnostic ignored "-Wincompatible-property-type" +#pragma clang diagnostic ignored "-Wnullability" + +#pragma push_macro("_Nullable_result") +#if !__has_feature(nullability_nullable_result) +#undef _Nullable_result +#define _Nullable_result _Nullable +#endif + +__attribute__((swift_name("KotlinBase"))) +@interface SMSBase : NSObject +- (instancetype)init __attribute__((unavailable)); ++ (instancetype)new __attribute__((unavailable)); ++ (void)initialize __attribute__((objc_requires_super)); +@end + +@interface SMSBase (SMSBaseCopying) +@end + +__attribute__((swift_name("KotlinMutableSet"))) +@interface SMSMutableSet : NSMutableSet +@end + +__attribute__((swift_name("KotlinMutableDictionary"))) +@interface SMSMutableDictionary : NSMutableDictionary +@end + +@interface NSError (NSErrorSMSKotlinException) +@property (readonly) id _Nullable kotlinException; +@end + +__attribute__((swift_name("KotlinNumber"))) +@interface SMSNumber : NSNumber +- (instancetype)initWithChar:(char)value __attribute__((unavailable)); +- (instancetype)initWithUnsignedChar:(unsigned char)value __attribute__((unavailable)); +- (instancetype)initWithShort:(short)value __attribute__((unavailable)); +- (instancetype)initWithUnsignedShort:(unsigned short)value __attribute__((unavailable)); +- (instancetype)initWithInt:(int)value __attribute__((unavailable)); +- (instancetype)initWithUnsignedInt:(unsigned int)value __attribute__((unavailable)); +- (instancetype)initWithLong:(long)value __attribute__((unavailable)); +- (instancetype)initWithUnsignedLong:(unsigned long)value __attribute__((unavailable)); +- (instancetype)initWithLongLong:(long long)value __attribute__((unavailable)); +- (instancetype)initWithUnsignedLongLong:(unsigned long long)value __attribute__((unavailable)); +- (instancetype)initWithFloat:(float)value __attribute__((unavailable)); +- (instancetype)initWithDouble:(double)value __attribute__((unavailable)); +- (instancetype)initWithBool:(BOOL)value __attribute__((unavailable)); +- (instancetype)initWithInteger:(NSInteger)value __attribute__((unavailable)); +- (instancetype)initWithUnsignedInteger:(NSUInteger)value __attribute__((unavailable)); ++ (instancetype)numberWithChar:(char)value __attribute__((unavailable)); ++ (instancetype)numberWithUnsignedChar:(unsigned char)value __attribute__((unavailable)); ++ (instancetype)numberWithShort:(short)value __attribute__((unavailable)); ++ (instancetype)numberWithUnsignedShort:(unsigned short)value __attribute__((unavailable)); ++ (instancetype)numberWithInt:(int)value __attribute__((unavailable)); ++ (instancetype)numberWithUnsignedInt:(unsigned int)value __attribute__((unavailable)); ++ (instancetype)numberWithLong:(long)value __attribute__((unavailable)); ++ (instancetype)numberWithUnsignedLong:(unsigned long)value __attribute__((unavailable)); ++ (instancetype)numberWithLongLong:(long long)value __attribute__((unavailable)); ++ (instancetype)numberWithUnsignedLongLong:(unsigned long long)value __attribute__((unavailable)); ++ (instancetype)numberWithFloat:(float)value __attribute__((unavailable)); ++ (instancetype)numberWithDouble:(double)value __attribute__((unavailable)); ++ (instancetype)numberWithBool:(BOOL)value __attribute__((unavailable)); ++ (instancetype)numberWithInteger:(NSInteger)value __attribute__((unavailable)); ++ (instancetype)numberWithUnsignedInteger:(NSUInteger)value __attribute__((unavailable)); +@end + +__attribute__((swift_name("KotlinByte"))) +@interface SMSByte : SMSNumber +- (instancetype)initWithChar:(char)value; ++ (instancetype)numberWithChar:(char)value; +@end + +__attribute__((swift_name("KotlinUByte"))) +@interface SMSUByte : SMSNumber +- (instancetype)initWithUnsignedChar:(unsigned char)value; ++ (instancetype)numberWithUnsignedChar:(unsigned char)value; +@end + +__attribute__((swift_name("KotlinShort"))) +@interface SMSShort : SMSNumber +- (instancetype)initWithShort:(short)value; ++ (instancetype)numberWithShort:(short)value; +@end + +__attribute__((swift_name("KotlinUShort"))) +@interface SMSUShort : SMSNumber +- (instancetype)initWithUnsignedShort:(unsigned short)value; ++ (instancetype)numberWithUnsignedShort:(unsigned short)value; +@end + +__attribute__((swift_name("KotlinInt"))) +@interface SMSInt : SMSNumber +- (instancetype)initWithInt:(int)value; ++ (instancetype)numberWithInt:(int)value; +@end + +__attribute__((swift_name("KotlinUInt"))) +@interface SMSUInt : SMSNumber +- (instancetype)initWithUnsignedInt:(unsigned int)value; ++ (instancetype)numberWithUnsignedInt:(unsigned int)value; +@end + +__attribute__((swift_name("KotlinLong"))) +@interface SMSLong : SMSNumber +- (instancetype)initWithLongLong:(long long)value; ++ (instancetype)numberWithLongLong:(long long)value; +@end + +__attribute__((swift_name("KotlinULong"))) +@interface SMSULong : SMSNumber +- (instancetype)initWithUnsignedLongLong:(unsigned long long)value; ++ (instancetype)numberWithUnsignedLongLong:(unsigned long long)value; +@end + +__attribute__((swift_name("KotlinFloat"))) +@interface SMSFloat : SMSNumber +- (instancetype)initWithFloat:(float)value; ++ (instancetype)numberWithFloat:(float)value; +@end + +__attribute__((swift_name("KotlinDouble"))) +@interface SMSDouble : SMSNumber +- (instancetype)initWithDouble:(double)value; ++ (instancetype)numberWithDouble:(double)value; +@end + +__attribute__((swift_name("KotlinBoolean"))) +@interface SMSBoolean : SMSNumber +- (instancetype)initWithBool:(BOOL)value; ++ (instancetype)numberWithBool:(BOOL)value; +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("SkieColdFlowIterator"))) +@interface SMSSkieColdFlowIterator : SMSBase +- (instancetype)initWithFlow:(id)flow __attribute__((swift_name("init(flow:)"))) __attribute__((objc_designated_initializer)); +- (void)cancel __attribute__((swift_name("cancel()"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)hasNextWithCompletionHandler:(void (^)(SMSBoolean * _Nullable, NSError * _Nullable))completionHandler __attribute__((swift_name("hasNext(completionHandler:)"))); +- (E _Nullable)next __attribute__((swift_name("next()"))); +@end + +__attribute__((swift_name("Kotlinx_coroutines_coreFlow"))) +@protocol SMSKotlinx_coroutines_coreFlow +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)collectCollector:(id)collector completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("collect(collector:completionHandler:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("SkieKotlinFlow"))) +@interface SMSSkieKotlinFlow<__covariant T> : SMSBase +- (instancetype)initWithDelegate:(id)delegate __attribute__((swift_name("init(_:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)collectCollector:(id)collector completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("collect(collector:completionHandler:)"))); +@end + +__attribute__((swift_name("Kotlinx_coroutines_coreSharedFlow"))) +@protocol SMSKotlinx_coroutines_coreSharedFlow +@required +@property (readonly) NSArray *replayCache __attribute__((swift_name("replayCache"))); +@end + +__attribute__((swift_name("Kotlinx_coroutines_coreFlowCollector"))) +@protocol SMSKotlinx_coroutines_coreFlowCollector +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)emitValue:(id _Nullable)value completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("emit(value:completionHandler:)"))); +@end + +__attribute__((swift_name("Kotlinx_coroutines_coreMutableSharedFlow"))) +@protocol SMSKotlinx_coroutines_coreMutableSharedFlow +@required + +/** + * @note annotations + * kotlinx.coroutines.ExperimentalCoroutinesApi +*/ +- (void)resetReplayCache __attribute__((swift_name("resetReplayCache()"))); +- (BOOL)tryEmitValue:(id _Nullable)value __attribute__((swift_name("tryEmit(value:)"))); +@property (readonly) id subscriptionCount __attribute__((swift_name("subscriptionCount"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("SkieKotlinMutableSharedFlow"))) +@interface SMSSkieKotlinMutableSharedFlow : SMSBase +@property (readonly) NSArray *replayCache __attribute__((swift_name("replayCache"))); +@property (readonly) id subscriptionCount __attribute__((swift_name("subscriptionCount"))); +- (instancetype)initWithDelegate:(id)delegate __attribute__((swift_name("init(_:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)collectCollector:(id)collector completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("collect(collector:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)emitValue:(T)value completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("emit(value:completionHandler:)"))); + +/** + * @note annotations + * kotlinx.coroutines.ExperimentalCoroutinesApi +*/ +- (void)resetReplayCache __attribute__((swift_name("resetReplayCache()"))); +- (BOOL)tryEmitValue:(T)value __attribute__((swift_name("tryEmit(value:)"))); +@end + +__attribute__((swift_name("Kotlinx_coroutines_coreStateFlow"))) +@protocol SMSKotlinx_coroutines_coreStateFlow +@required +@property (readonly) id _Nullable value __attribute__((swift_name("value"))); +@end + +__attribute__((swift_name("Kotlinx_coroutines_coreMutableStateFlow"))) +@protocol SMSKotlinx_coroutines_coreMutableStateFlow +@required +- (void)setValue:(id _Nullable)value __attribute__((swift_name("setValue(_:)"))); +- (BOOL)compareAndSetExpect:(id _Nullable)expect update:(id _Nullable)update __attribute__((swift_name("compareAndSet(expect:update:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("SkieKotlinMutableStateFlow"))) +@interface SMSSkieKotlinMutableStateFlow : SMSBase +@property (readonly) NSArray *replayCache __attribute__((swift_name("replayCache"))); +@property (readonly) id subscriptionCount __attribute__((swift_name("subscriptionCount"))); +@property T value __attribute__((swift_name("value"))); +- (instancetype)initWithDelegate:(id)delegate __attribute__((swift_name("init(_:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)collectCollector:(id)collector completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("collect(collector:completionHandler:)"))); +- (BOOL)compareAndSetExpect:(T)expect update:(T)update __attribute__((swift_name("compareAndSet(expect:update:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)emitValue:(T)value completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("emit(value:completionHandler:)"))); + +/** + * @note annotations + * kotlinx.coroutines.ExperimentalCoroutinesApi +*/ +- (void)resetReplayCache __attribute__((swift_name("resetReplayCache()"))); +- (BOOL)tryEmitValue:(T)value __attribute__((swift_name("tryEmit(value:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("SkieKotlinOptionalFlow"))) +@interface SMSSkieKotlinOptionalFlow<__covariant T> : SMSBase +- (instancetype)initWithDelegate:(id)delegate __attribute__((swift_name("init(_:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)collectCollector:(id)collector completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("collect(collector:completionHandler:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("SkieKotlinOptionalMutableSharedFlow"))) +@interface SMSSkieKotlinOptionalMutableSharedFlow : SMSBase +@property (readonly) NSArray *replayCache __attribute__((swift_name("replayCache"))); +@property (readonly) id subscriptionCount __attribute__((swift_name("subscriptionCount"))); +- (instancetype)initWithDelegate:(id)delegate __attribute__((swift_name("init(_:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)collectCollector:(id)collector completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("collect(collector:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)emitValue:(T _Nullable)value completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("emit(value:completionHandler:)"))); + +/** + * @note annotations + * kotlinx.coroutines.ExperimentalCoroutinesApi +*/ +- (void)resetReplayCache __attribute__((swift_name("resetReplayCache()"))); +- (BOOL)tryEmitValue:(T _Nullable)value __attribute__((swift_name("tryEmit(value:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("SkieKotlinOptionalMutableStateFlow"))) +@interface SMSSkieKotlinOptionalMutableStateFlow : SMSBase +@property (readonly) NSArray *replayCache __attribute__((swift_name("replayCache"))); +@property (readonly) id subscriptionCount __attribute__((swift_name("subscriptionCount"))); +@property T _Nullable value __attribute__((swift_name("value"))); +- (instancetype)initWithDelegate:(id)delegate __attribute__((swift_name("init(_:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)collectCollector:(id)collector completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("collect(collector:completionHandler:)"))); +- (BOOL)compareAndSetExpect:(T _Nullable)expect update:(T _Nullable)update __attribute__((swift_name("compareAndSet(expect:update:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)emitValue:(T _Nullable)value completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("emit(value:completionHandler:)"))); + +/** + * @note annotations + * kotlinx.coroutines.ExperimentalCoroutinesApi +*/ +- (void)resetReplayCache __attribute__((swift_name("resetReplayCache()"))); +- (BOOL)tryEmitValue:(T _Nullable)value __attribute__((swift_name("tryEmit(value:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("SkieKotlinOptionalSharedFlow"))) +@interface SMSSkieKotlinOptionalSharedFlow<__covariant T> : SMSBase +@property (readonly) NSArray *replayCache __attribute__((swift_name("replayCache"))); +- (instancetype)initWithDelegate:(id)delegate __attribute__((swift_name("init(_:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)collectCollector:(id)collector completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("collect(collector:completionHandler:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("SkieKotlinOptionalStateFlow"))) +@interface SMSSkieKotlinOptionalStateFlow<__covariant T> : SMSBase +@property (readonly) NSArray *replayCache __attribute__((swift_name("replayCache"))); +@property (readonly) T _Nullable value __attribute__((swift_name("value"))); +- (instancetype)initWithDelegate:(id)delegate __attribute__((swift_name("init(_:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)collectCollector:(id)collector completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("collect(collector:completionHandler:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("SkieKotlinSharedFlow"))) +@interface SMSSkieKotlinSharedFlow<__covariant T> : SMSBase +@property (readonly) NSArray *replayCache __attribute__((swift_name("replayCache"))); +- (instancetype)initWithDelegate:(id)delegate __attribute__((swift_name("init(_:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)collectCollector:(id)collector completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("collect(collector:completionHandler:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("SkieKotlinStateFlow"))) +@interface SMSSkieKotlinStateFlow<__covariant T> : SMSBase +@property (readonly) NSArray *replayCache __attribute__((swift_name("replayCache"))); +@property (readonly) T value __attribute__((swift_name("value"))); +- (instancetype)initWithDelegate:(id)delegate __attribute__((swift_name("init(_:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)collectCollector:(id)collector completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("collect(collector:completionHandler:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Skie_CancellationHandler"))) +@interface SMSSkie_CancellationHandler : SMSBase +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); ++ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); +- (void)cancel __attribute__((swift_name("cancel()"))); +@end + +__attribute__((swift_name("Skie_DispatcherDelegate"))) +@protocol SMSSkie_DispatcherDelegate +@required +- (void)dispatchBlock:(id)block __attribute__((swift_name("dispatch(block:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Skie_SuspendHandler"))) +@interface SMSSkie_SuspendHandler : SMSBase +- (instancetype)initWithCancellationHandler:(SMSSkie_CancellationHandler *)cancellationHandler dispatcherDelegate:(id)dispatcherDelegate onResult:(void (^)(SMSSkie_SuspendResult *))onResult __attribute__((swift_name("init(cancellationHandler:dispatcherDelegate:onResult:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((swift_name("Skie_SuspendResult"))) +@interface SMSSkie_SuspendResult : SMSBase +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Skie_SuspendResult.Canceled"))) +@interface SMSSkie_SuspendResultCanceled : SMSSkie_SuspendResult +@property (class, readonly, getter=shared) SMSSkie_SuspendResultCanceled *shared __attribute__((swift_name("shared"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)canceled __attribute__((swift_name("init()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Skie_SuspendResult.Error"))) +@interface SMSSkie_SuspendResultError : SMSSkie_SuspendResult +@property (readonly) NSError *error __attribute__((swift_name("error"))); +- (instancetype)initWithError:(NSError *)error __attribute__((swift_name("init(error:)"))) __attribute__((objc_designated_initializer)); +- (SMSSkie_SuspendResultError *)doCopyError:(NSError *)error __attribute__((swift_name("doCopy(error:)"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Skie_SuspendResult.Success"))) +@interface SMSSkie_SuspendResultSuccess : SMSSkie_SuspendResult +@property (readonly) id _Nullable value __attribute__((swift_name("value"))); +- (instancetype)initWithValue:(id _Nullable)value __attribute__((swift_name("init(value:)"))) __attribute__((objc_designated_initializer)); +- (SMSSkie_SuspendResultSuccess *)doCopyValue:(id _Nullable)value __attribute__((swift_name("doCopy(value:)"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("DeadLetter"))) +@interface SMSDeadLetter : SMSBase +@property (readonly) int32_t attempts __attribute__((swift_name("attempts"))); +@property (readonly) NSString *canonicalId __attribute__((swift_name("canonicalId"))); +@property (readonly) SMSMutationFailure *failure __attribute__((swift_name("failure"))); +@property (readonly) int32_t generation __attribute__((swift_name("generation"))); +@property (readonly) NSString *mutationId __attribute__((swift_name("mutationId"))); +@property (readonly) NSString *mutatorId __attribute__((swift_name("mutatorId"))); +@property (readonly, getter=namespace) NSString *namespace_ __attribute__((swift_name("namespace_"))); +@property (readonly) int64_t parkedAtEpochMillis __attribute__((swift_name("parkedAtEpochMillis"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((swift_name("MutationAck"))) +@protocol SMSMutationAck +@required + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString * _Nullable etag __attribute__((swift_name("etag"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationAbsentAck"))) +@interface SMSMutationAbsentAck : SMSBase +@property (readonly) NSString * _Nullable etag __attribute__((swift_name("etag"))); +- (instancetype)initWithEtag:(NSString * _Nullable)etag __attribute__((swift_name("init(etag:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((swift_name("MutationEvent"))) +@protocol SMSMutationEvent +@required + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) int64_t occurredAtEpochMillis __attribute__((swift_name("occurredAtEpochMillis"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((swift_name("MutationIntentEvent"))) +@protocol SMSMutationIntentEvent +@required + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) SMSMutationKeyIdentity *identity __attribute__((swift_name("identity"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@property (readonly) NSString *mutationId __attribute__((swift_name("mutationId"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationAcknowledged"))) +@interface SMSMutationAcknowledged : SMSBase +@property (readonly) int32_t generation __attribute__((swift_name("generation"))); +@property (readonly) SMSMutationKeyIdentity *identity __attribute__((swift_name("identity"))); +@property (readonly) NSString *mutationId __attribute__((swift_name("mutationId"))); +@property (readonly) int64_t occurredAtEpochMillis __attribute__((swift_name("occurredAtEpochMillis"))); +@property (readonly) SMSMutationPresenceState *presence __attribute__((swift_name("presence"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationAdopted"))) +@interface SMSMutationAdopted : SMSBase +@property (readonly) int32_t generation __attribute__((swift_name("generation"))); +@property (readonly) SMSMutationKeyIdentity *identity __attribute__((swift_name("identity"))); +@property (readonly) NSString *mutationId __attribute__((swift_name("mutationId"))); +@property (readonly) int64_t occurredAtEpochMillis __attribute__((swift_name("occurredAtEpochMillis"))); +@property (readonly) SMSMutationPresenceState *presence __attribute__((swift_name("presence"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationAttempted"))) +@interface SMSMutationAttempted : SMSBase +@property (readonly) int32_t attempt __attribute__((swift_name("attempt"))); +@property (readonly) int32_t generation __attribute__((swift_name("generation"))); +@property (readonly) SMSMutationKeyIdentity *identity __attribute__((swift_name("identity"))); +@property (readonly) NSString *mutationId __attribute__((swift_name("mutationId"))); +@property (readonly) int64_t occurredAtEpochMillis __attribute__((swift_name("occurredAtEpochMillis"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationCheckpointConfirmed"))) +@interface SMSMutationCheckpointConfirmed : SMSBase +@property (readonly) NSString *clientId __attribute__((swift_name("clientId"))); +@property (readonly) int64_t confirmedThroughSequence __attribute__((swift_name("confirmedThroughSequence"))); +@property (readonly) int64_t occurredAtEpochMillis __attribute__((swift_name("occurredAtEpochMillis"))); +@property (readonly) int64_t requestedThroughSequence __attribute__((swift_name("requestedThroughSequence"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationCheckpointFailed"))) +@interface SMSMutationCheckpointFailed : SMSBase +@property (readonly) NSString *clientId __attribute__((swift_name("clientId"))); +@property (readonly) SMSMutationFailure *failure __attribute__((swift_name("failure"))); +@property (readonly) int64_t occurredAtEpochMillis __attribute__((swift_name("occurredAtEpochMillis"))); +@property (readonly) int64_t requestedThroughSequence __attribute__((swift_name("requestedThroughSequence"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((swift_name("MutationCodec"))) +@protocol SMSMutationCodec +@required + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (id)decodeVersion:(int32_t)version bytes:(SMSKotlinByteArray *)bytes __attribute__((swift_name("decode(version:bytes:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (SMSKotlinByteArray *)encodeValue:(id)value __attribute__((swift_name("encode(value:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationConflictBuilder"))) +@interface SMSMutationConflictBuilder : SMSBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)mergeMerge:(id (^)(id, id, id))merge __attribute__((swift_name("merge(merge:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)preconditionSelect:(id _Nullable (^)(SMSMutationPreconditionCandidate *))select __attribute__((swift_name("precondition(select:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationConflictObserved"))) +@interface SMSMutationConflictObserved : SMSBase +@property (readonly) int32_t generation __attribute__((swift_name("generation"))); +@property (readonly) SMSMutationKeyIdentity *identity __attribute__((swift_name("identity"))); +@property (readonly) NSString *mutationId __attribute__((swift_name("mutationId"))); +@property (readonly) int64_t occurredAtEpochMillis __attribute__((swift_name("occurredAtEpochMillis"))); +@property (readonly) id _Nullable serverMeta __attribute__((swift_name("serverMeta"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((swift_name("MutationConflictResolution"))) +@protocol SMSMutationConflictResolution +@required +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationConflictResolutionRetry"))) +@interface SMSMutationConflictResolutionRetry : SMSBase +@property (readonly) id value __attribute__((swift_name("value"))); +- (instancetype)initWithValue:(id)value __attribute__((swift_name("init(value:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationConflictResolutionServerWins"))) +@interface SMSMutationConflictResolutionServerWins : SMSBase +@property (class, readonly, getter=shared) SMSMutationConflictResolutionServerWins *shared __attribute__((swift_name("shared"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)serverWins __attribute__((swift_name("init()"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationEffectApplied"))) +@interface SMSMutationEffectApplied : SMSBase +@property (readonly) int32_t effectIndex __attribute__((swift_name("effectIndex"))); +@property (readonly) int32_t generation __attribute__((swift_name("generation"))); +@property (readonly) SMSMutationKeyIdentity *identity __attribute__((swift_name("identity"))); +@property (readonly) NSString *mutationId __attribute__((swift_name("mutationId"))); +@property (readonly) int64_t occurredAtEpochMillis __attribute__((swift_name("occurredAtEpochMillis"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationEffectSkipped"))) +@interface SMSMutationEffectSkipped : SMSBase +@property (readonly) int32_t effectIndex __attribute__((swift_name("effectIndex"))); +@property (readonly) int32_t generation __attribute__((swift_name("generation"))); +@property (readonly) SMSMutationKeyIdentity *identity __attribute__((swift_name("identity"))); +@property (readonly) NSString *mutationId __attribute__((swift_name("mutationId"))); +@property (readonly) int64_t occurredAtEpochMillis __attribute__((swift_name("occurredAtEpochMillis"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationEnqueued"))) +@interface SMSMutationEnqueued : SMSBase +@property (readonly) int64_t clientSequence __attribute__((swift_name("clientSequence"))); +@property (readonly) SMSMutationKeyIdentity *identity __attribute__((swift_name("identity"))); +@property (readonly) NSString *mutationId __attribute__((swift_name("mutationId"))); +@property (readonly) NSString *mutatorId __attribute__((swift_name("mutatorId"))); +@property (readonly) int64_t occurredAtEpochMillis __attribute__((swift_name("occurredAtEpochMillis"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationFailed"))) +@interface SMSMutationFailed : SMSBase +@property (readonly) SMSMutationFailure *failure __attribute__((swift_name("failure"))); +@property (readonly) int32_t generation __attribute__((swift_name("generation"))); +@property (readonly) SMSMutationKeyIdentity *identity __attribute__((swift_name("identity"))); +@property (readonly) NSString *mutationId __attribute__((swift_name("mutationId"))); +@property (readonly) int64_t occurredAtEpochMillis __attribute__((swift_name("occurredAtEpochMillis"))); +@property (readonly) SMSMutationPendingState *state __attribute__((swift_name("state"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationFailure"))) +@interface SMSMutationFailure : SMSBase +@property (readonly) NSString *detail __attribute__((swift_name("detail"))); +@property (readonly) SMSMutationFailureKind *kind __attribute__((swift_name("kind"))); +@property (readonly) NSString *message __attribute__((swift_name("message"))); +@property (readonly) int64_t occurredAtEpochMillis __attribute__((swift_name("occurredAtEpochMillis"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@end + +__attribute__((swift_name("KotlinComparable"))) +@protocol SMSKotlinComparable +@required +- (int32_t)compareToOther:(id _Nullable)other __attribute__((swift_name("compareTo(other:)"))); +@end + +__attribute__((swift_name("KotlinEnum"))) +@interface SMSKotlinEnum : SMSBase +@property (class, readonly, getter=companion) SMSKotlinEnumCompanion *companion __attribute__((swift_name("companion"))); +@property (readonly) NSString *name __attribute__((swift_name("name"))); +@property (readonly) int32_t ordinal __attribute__((swift_name("ordinal"))); +- (instancetype)initWithName:(NSString *)name ordinal:(int32_t)ordinal __attribute__((swift_name("init(name:ordinal:)"))) __attribute__((objc_designated_initializer)); +- (int32_t)compareToOther:(E)other __attribute__((swift_name("compareTo(other:)"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationFailureKind"))) +@interface SMSMutationFailureKind : SMSKotlinEnum +@property (class, readonly) SMSMutationFailureKind *identity __attribute__((swift_name("identity"))); +@property (class, readonly) SMSMutationFailureKind *codec __attribute__((swift_name("codec"))); +@property (class, readonly) SMSMutationFailureKind *projection __attribute__((swift_name("projection"))); +@property (class, readonly) SMSMutationFailureKind *protocol __attribute__((swift_name("protocol"))); +@property (class, readonly) SMSMutationFailureKind *conflict __attribute__((swift_name("conflict"))); +@property (class, readonly) SMSMutationFailureKind *transport __attribute__((swift_name("transport"))); +@property (class, readonly) SMSMutationFailureKind *adoption __attribute__((swift_name("adoption"))); +@property (class, readonly) SMSMutationFailureKind *effect __attribute__((swift_name("effect"))); +@property (class, readonly) SMSMutationFailureKind *persistence __attribute__((swift_name("persistence"))); +@property (class, readonly) NSArray *entries __attribute__((swift_name("entries"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); +- (instancetype)initWithName:(NSString *)name ordinal:(int32_t)ordinal __attribute__((swift_name("init(name:ordinal:)"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable)); ++ (SMSKotlinArray *)values __attribute__((swift_name("values()"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationKeyIdentity"))) +@interface SMSMutationKeyIdentity : SMSBase +@property (readonly) NSString *canonicalId __attribute__((swift_name("canonicalId"))); +@property (readonly, getter=namespace) NSString *namespace_ __attribute__((swift_name("namespace_"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((swift_name("MutationKeyResolver"))) +@protocol SMSMutationKeyResolver +@required + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)resolveIdentity:(SMSMutationKeyIdentity *)identity completionHandler:(void (^)(id _Nullable_result, NSError * _Nullable))completionHandler __attribute__((swift_name("resolve(identity:completionHandler:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationParked"))) +@interface SMSMutationParked : SMSBase +@property (readonly) SMSMutationFailure *failure __attribute__((swift_name("failure"))); +@property (readonly) int32_t generation __attribute__((swift_name("generation"))); +@property (readonly) SMSMutationKeyIdentity *identity __attribute__((swift_name("identity"))); +@property (readonly) NSString *mutationId __attribute__((swift_name("mutationId"))); +@property (readonly) int64_t occurredAtEpochMillis __attribute__((swift_name("occurredAtEpochMillis"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationPendingState"))) +@interface SMSMutationPendingState : SMSKotlinEnum +@property (class, readonly) SMSMutationPendingState *pending __attribute__((swift_name("pending"))); +@property (class, readonly) SMSMutationPendingState *inflight __attribute__((swift_name("inflight"))); +@property (class, readonly) SMSMutationPendingState *refreshing __attribute__((swift_name("refreshing"))); +@property (class, readonly) SMSMutationPendingState *adopting __attribute__((swift_name("adopting"))); +@property (class, readonly) SMSMutationPendingState *applyingEffects __attribute__((swift_name("applyingEffects"))); +@property (class, readonly) NSArray *entries __attribute__((swift_name("entries"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); +- (instancetype)initWithName:(NSString *)name ordinal:(int32_t)ordinal __attribute__((swift_name("init(name:ordinal:)"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable)); ++ (SMSKotlinArray *)values __attribute__((swift_name("values()"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationPreconditionCandidate"))) +@interface SMSMutationPreconditionCandidate : SMSBase +@property (readonly) id base __attribute__((swift_name("base"))); +@property (readonly) id _Nullable capturedMeta __attribute__((swift_name("capturedMeta"))); +@property (readonly) int32_t generation __attribute__((swift_name("generation"))); +@property (readonly) SMSMutationKeyIdentity *identity __attribute__((swift_name("identity"))); +@property (readonly) K key __attribute__((swift_name("key"))); +@property (readonly) id mine __attribute__((swift_name("mine"))); +@property (readonly) NSString *mutationId __attribute__((swift_name("mutationId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((swift_name("MutationPresence"))) +@protocol SMSMutationPresence +@required +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationPresenceAbsent"))) +@interface SMSMutationPresenceAbsent : SMSBase +@property (class, readonly, getter=shared) SMSMutationPresenceAbsent *shared __attribute__((swift_name("shared"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)absent __attribute__((swift_name("init()"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationPresencePresent"))) +@interface SMSMutationPresencePresent : SMSBase +@property (readonly) V value __attribute__((swift_name("value"))); +- (instancetype)initWithValue:(V)value __attribute__((swift_name("init(value:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationPresenceState"))) +@interface SMSMutationPresenceState : SMSKotlinEnum +@property (class, readonly) SMSMutationPresenceState *present __attribute__((swift_name("present"))); +@property (class, readonly) SMSMutationPresenceState *absent __attribute__((swift_name("absent"))); +@property (class, readonly) NSArray *entries __attribute__((swift_name("entries"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); +- (instancetype)initWithName:(NSString *)name ordinal:(int32_t)ordinal __attribute__((swift_name("init(name:ordinal:)"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable)); ++ (SMSKotlinArray *)values __attribute__((swift_name("values()"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationPresentAck"))) +@interface SMSMutationPresentAck : SMSBase +@property (readonly) V authoritative __attribute__((swift_name("authoritative"))); +@property (readonly) K _Nullable canonicalKey __attribute__((swift_name("canonicalKey"))); +@property (readonly) NSString * _Nullable etag __attribute__((swift_name("etag"))); +- (instancetype)initWithAuthoritative:(V)authoritative etag:(NSString * _Nullable)etag canonicalKey:(K _Nullable)canonicalKey __attribute__((swift_name("init(authoritative:etag:canonicalKey:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationPush"))) +@interface SMSMutationPush : SMSBase +@property (readonly) id base __attribute__((swift_name("base"))); +@property (readonly) id _Nullable baseMeta __attribute__((swift_name("baseMeta"))); +@property (readonly) NSString *clientId __attribute__((swift_name("clientId"))); +@property (readonly) int64_t clientSequence __attribute__((swift_name("clientSequence"))); +@property (readonly) int32_t generation __attribute__((swift_name("generation"))); +@property (readonly) NSString *idempotencyKey __attribute__((swift_name("idempotencyKey"))); +@property (readonly) SMSMutationKeyIdentity *identity __attribute__((swift_name("identity"))); +@property (readonly) K key __attribute__((swift_name("key"))); +@property (readonly) id mine __attribute__((swift_name("mine"))); +@property (readonly) NSString *mutationId __attribute__((swift_name("mutationId"))); +@property (readonly) int64_t retiredThroughSequence __attribute__((swift_name("retiredThroughSequence"))); +@property (readonly) int32_t valueCodecVersion __attribute__((swift_name("valueCodecVersion"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationRetired"))) +@interface SMSMutationRetired : SMSBase +@property (readonly) int32_t generation __attribute__((swift_name("generation"))); +@property (readonly) SMSMutationKeyIdentity *identity __attribute__((swift_name("identity"))); +@property (readonly) NSString *mutationId __attribute__((swift_name("mutationId"))); +@property (readonly) int64_t occurredAtEpochMillis __attribute__((swift_name("occurredAtEpochMillis"))); +@property (readonly) int64_t retiredThroughSequence __attribute__((swift_name("retiredThroughSequence"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationRetirement"))) +@interface SMSMutationRetirement : SMSBase +@property (readonly) NSString *clientId __attribute__((swift_name("clientId"))); +@property (readonly) int64_t retiredThroughSequence __attribute__((swift_name("retiredThroughSequence"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationRetirementAck"))) +@interface SMSMutationRetirementAck : SMSBase +@property (readonly) int64_t confirmedThroughSequence __attribute__((swift_name("confirmedThroughSequence"))); +- (instancetype)initWithConfirmedThroughSequence:(int64_t)confirmedThroughSequence __attribute__((swift_name("init(confirmedThroughSequence:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((swift_name("MutationServer"))) +@protocol SMSMutationServer +@required + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)pushRequest:(SMSMutationPush, id> *)request completionHandler:(void (^)(id _Nullable, NSError * _Nullable))completionHandler __attribute__((swift_name("push(request:completionHandler:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)retireRequest:(SMSMutationRetirement *)request completionHandler:(void (^)(SMSMutationRetirementAck * _Nullable, NSError * _Nullable))completionHandler __attribute__((swift_name("retire(request:completionHandler:)"))); +@end + + +/** + * @note annotations + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("Store6_coreStore"))) +@protocol SMSStore6_coreStore +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)clearKey:(id)key completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("clear(key:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)clearAllWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("clearAll(completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)clearNamespaceNamespace:(SMSStore6_coreStoreNamespace *)namespace_ completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("clearNamespace(namespace:completionHandler:)"))); +- (void)close __attribute__((swift_name("close()"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)getKey:(id)key freshness:(id)freshness completionHandler:(void (^)(id _Nullable, NSError * _Nullable))completionHandler __attribute__((swift_name("get(key:freshness:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)invalidateKey:(id)key completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("invalidate(key:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)invalidateAllWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("invalidateAll(completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)invalidateNamespaceNamespace:(SMSStore6_coreStoreNamespace *)namespace_ completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("invalidateNamespace(namespace:completionHandler:)"))); +- (id)streamKey:(id)key freshness:(id)freshness __attribute__((swift_name("stream(key:freshness:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationStore"))) +@interface SMSMutationStore : SMSBase +@property (readonly) id events __attribute__((swift_name("events"))); +@property (readonly) id keyEvents __attribute__((swift_name("keyEvents"))); +@property (readonly) id poisoned __attribute__((swift_name("poisoned"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)clearKey:(K)key completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("clear(key:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)clearAllWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("clearAll(completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)clearNamespaceNamespace:(SMSStore6_coreStoreNamespace *)namespace_ completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("clearNamespace(namespace:completionHandler:)"))); +- (void)close __attribute__((swift_name("close()"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)deadLettersWithCompletionHandler:(void (^)(NSArray * _Nullable, NSError * _Nullable))completionHandler __attribute__((swift_name("deadLetters(completionHandler:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)drainWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("drain(completionHandler:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)drainKey:(K)key completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("drain(key:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)getKey:(K)key freshness:(id)freshness completionHandler:(void (^)(V _Nullable, NSError * _Nullable))completionHandler __attribute__((swift_name("get(key:freshness:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)invalidateKey:(K)key completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("invalidate(key:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)invalidateAllWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("invalidateAll(completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)invalidateNamespaceNamespace:(SMSStore6_coreStoreNamespace *)namespace_ completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("invalidateNamespace(namespace:completionHandler:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)mutateKey:(K)key ref:(SMSMutatorRef *)ref args:(id)args completionHandler:(void (^)(NSString * _Nullable, NSError * _Nullable))completionHandler __attribute__((swift_name("mutate(key:ref:args:completionHandler:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)pendingKey:(K)key completionHandler:(void (^)(NSArray * _Nullable, NSError * _Nullable))completionHandler __attribute__((swift_name("pending(key:completionHandler:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)pendingWritesWithCompletionHandler:(void (^)(NSArray * _Nullable, NSError * _Nullable))completionHandler __attribute__((swift_name("pendingWrites(completionHandler:)"))); +- (id)streamKey:(K)key freshness:(id)freshness __attribute__((swift_name("stream(key:freshness:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationStoreBuilder"))) +@interface SMSMutationStoreBuilder : SMSBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)bookkeeperBookkeeper:(id)bookkeeper __attribute__((swift_name("bookkeeper(bookkeeper:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)conflictsConfigure:(void (^)(SMSMutationConflictBuilder *))configure __attribute__((swift_name("conflicts(configure:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)fetcherFetch:(id)fetch __attribute__((swift_name("fetcher(fetch:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)fetcherFetcher:(id)fetcher __attribute__((swift_name("fetcher(fetcher:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)fetcherOfResultFetch:(id)fetch __attribute__((swift_name("fetcherOfResult(fetch:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)freshnessValidatorValidator:(id)validator __attribute__((swift_name("freshnessValidator(validator:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)journalStorageStorage:(id)storage __attribute__((swift_name("journalStorage(storage:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)maxIdleKeysCount:(int32_t)count __attribute__((swift_name("maxIdleKeys(count:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)persistenceSot:(id)sot __attribute__((swift_name("persistence(sot:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)telemetryTelemetry:(id)telemetry __attribute__((swift_name("telemetry(telemetry:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)wallClockWallClock:(id)wallClock __attribute__((swift_name("wallClock(wallClock:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutatorRef"))) +@interface SMSMutatorRef : SMSBase +@property (readonly) NSString *id __attribute__((swift_name("id"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutatorRegistry"))) +@interface SMSMutatorRegistry : SMSBase +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutatorRegistryBuilder"))) +@interface SMSMutatorRegistryBuilder : SMSBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (SMSMutatorRef *)createId:(NSString *)id version:(int32_t)version codec:(id)codec stales:(SMSStaleSet *(^)(K, id))stales project:(V (^)(id))project __attribute__((swift_name("create(id:version:codec:stales:project:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (SMSMutatorRef *)deleteId:(NSString *)id stales:(SMSStaleSet *(^)(K, SMSKotlinUnit *))stales __attribute__((swift_name("delete(id:stales:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (SMSMutatorRef *)mutatorId:(NSString *)id version:(int32_t)version codec:(id)codec stales:(SMSStaleSet *(^)(K, id))stales project:(id _Nullable (^)(id, id))project __attribute__((swift_name("mutator(id:version:codec:stales:project:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (SMSMutatorRef *)updateId:(NSString *)id version:(int32_t)version codec:(id)codec stales:(SMSStaleSet *(^)(K, id))stales project:(V (^)(V, id))project __attribute__((swift_name("update(id:version:codec:stales:project:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (SMSMutatorRef *)upsertId:(NSString *)id version:(int32_t)version codec:(id)codec stales:(SMSStaleSet *(^)(K, id))stales project:(id (^)(id, id))project __attribute__((swift_name("upsert(id:version:codec:stales:project:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("PendingIntent"))) +@interface SMSPendingIntent : SMSBase +@property (readonly) int32_t attempt __attribute__((swift_name("attempt"))); +@property (readonly) NSString *canonicalId __attribute__((swift_name("canonicalId"))); +@property (readonly) int64_t createdAtEpochMillis __attribute__((swift_name("createdAtEpochMillis"))); +@property (readonly) NSString *mutationId __attribute__((swift_name("mutationId"))); +@property (readonly) NSString *mutatorId __attribute__((swift_name("mutatorId"))); +@property (readonly, getter=namespace) NSString *namespace_ __attribute__((swift_name("namespace_"))); +@property (readonly) SMSMutationPendingState *state __attribute__((swift_name("state"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("PoisonedIntent"))) +@interface SMSPoisonedIntent : SMSBase +@property (readonly) SMSKotlinThrowable *failure __attribute__((swift_name("failure"))); +@property (readonly) NSString *mutationId __attribute__((swift_name("mutationId"))); +@property (readonly) NSString *mutatorId __attribute__((swift_name("mutatorId"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StaleSet"))) +@interface SMSStaleSet : SMSBase +@property (readonly) NSSet *keys __attribute__((swift_name("keys"))); +@property (readonly) NSSet *namespaces __attribute__((swift_name("namespaces"))); +- (instancetype)initWithKeys:(NSSet *)keys namespaces:(NSSet *)namespaces __attribute__((swift_name("init(keys:namespaces:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("MutationJournalStorage"))) +@protocol SMSMutationJournalStorage +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)transactionBlock:(id _Nullable (^)(id))block completionHandler:(void (^)(id _Nullable_result, NSError * _Nullable))completionHandler __attribute__((swift_name("transaction(block:completionHandler:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("InMemoryMutationJournalStorage"))) +@interface SMSInMemoryMutationJournalStorage : SMSBase +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); ++ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)transactionBlock:(id _Nullable (^)(id))block completionHandler:(void (^)(id _Nullable_result, NSError * _Nullable))completionHandler __attribute__((swift_name("transaction(block:completionHandler:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationAckRecord"))) +@interface SMSMutationAckRecord : SMSBase +@property (readonly) SMSKotlinByteArray * _Nullable authoritativeBlob __attribute__((swift_name("authoritativeBlob"))); +@property (readonly) SMSMutationPresenceState *authoritativePresence __attribute__((swift_name("authoritativePresence"))); +@property (readonly) NSString * _Nullable canonicalTargetId __attribute__((swift_name("canonicalTargetId"))); +@property (readonly) NSString * _Nullable canonicalTargetNamespace __attribute__((swift_name("canonicalTargetNamespace"))); +@property (readonly) NSString *clientId __attribute__((swift_name("clientId"))); +@property (readonly) int64_t clientSequence __attribute__((swift_name("clientSequence"))); +@property (readonly) NSString * _Nullable etag __attribute__((swift_name("etag"))); +@property (readonly) int32_t generation __attribute__((swift_name("generation"))); +@property (readonly) int64_t receivedAt __attribute__((swift_name("receivedAt"))); +@property (readonly) int32_t valueCodecVersion __attribute__((swift_name("valueCodecVersion"))); +- (instancetype)initWithClientId:(NSString *)clientId clientSequence:(int64_t)clientSequence generation:(int32_t)generation authoritativePresence:(SMSMutationPresenceState *)authoritativePresence authoritativeBlob:(SMSKotlinByteArray * _Nullable)authoritativeBlob valueCodecVersion:(int32_t)valueCodecVersion etag:(NSString * _Nullable)etag canonicalTargetNamespace:(NSString * _Nullable)canonicalTargetNamespace canonicalTargetId:(NSString * _Nullable)canonicalTargetId receivedAt:(int64_t)receivedAt __attribute__((swift_name("init(clientId:clientSequence:generation:authoritativePresence:authoritativeBlob:valueCodecVersion:etag:canonicalTargetNamespace:canonicalTargetId:receivedAt:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationAliasState"))) +@interface SMSMutationAliasState : SMSKotlinEnum +@property (class, readonly) SMSMutationAliasState *pending __attribute__((swift_name("pending"))); +@property (class, readonly) SMSMutationAliasState *active __attribute__((swift_name("active"))); +@property (class, readonly) NSArray *entries __attribute__((swift_name("entries"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); +- (instancetype)initWithName:(NSString *)name ordinal:(int32_t)ordinal __attribute__((swift_name("init(name:ordinal:)"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable)); ++ (SMSKotlinArray *)values __attribute__((swift_name("values()"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationAttemptRecord"))) +@interface SMSMutationAttemptRecord : SMSBase +@property (readonly) int64_t advertisedRetiredThroughSequence __attribute__((swift_name("advertisedRetiredThroughSequence"))); +@property (readonly) SMSKotlinByteArray * _Nullable baseBlob __attribute__((swift_name("baseBlob"))); +@property (readonly) SMSMutationPresenceState *basePresence __attribute__((swift_name("basePresence"))); +@property (readonly) NSString *clientId __attribute__((swift_name("clientId"))); +@property (readonly) int64_t clientSequence __attribute__((swift_name("clientSequence"))); +@property (readonly) NSString * _Nullable conflictEtag __attribute__((swift_name("conflictEtag"))); +@property (readonly) SMSBoolean * _Nullable conflictMetaPresent __attribute__((swift_name("conflictMetaPresent"))); +@property (readonly) SMSLong * _Nullable conflictReceivedAt __attribute__((swift_name("conflictReceivedAt"))); +@property (readonly) SMSLong * _Nullable conflictWrittenAt __attribute__((swift_name("conflictWrittenAt"))); +@property (readonly) NSString *effectiveCanonicalId __attribute__((swift_name("effectiveCanonicalId"))); +@property (readonly) NSString *effectiveNamespace __attribute__((swift_name("effectiveNamespace"))); +@property (readonly) int32_t generation __attribute__((swift_name("generation"))); +@property (readonly) NSString *generationIdempotencyKey __attribute__((swift_name("generationIdempotencyKey"))); +@property (readonly) SMSKotlinByteArray * _Nullable mineBlob __attribute__((swift_name("mineBlob"))); +@property (readonly) SMSMutationPresenceState *minePresence __attribute__((swift_name("minePresence"))); +@property (readonly) NSString * _Nullable preconditionEtag __attribute__((swift_name("preconditionEtag"))); +@property (readonly) BOOL preconditionMetaPresent __attribute__((swift_name("preconditionMetaPresent"))); +@property (readonly) SMSLong * _Nullable preconditionWrittenAt __attribute__((swift_name("preconditionWrittenAt"))); +@property (readonly) int64_t preparedAt __attribute__((swift_name("preparedAt"))); +@property (readonly) int32_t valueCodecVersion __attribute__((swift_name("valueCodecVersion"))); +- (instancetype)initWithClientId:(NSString *)clientId clientSequence:(int64_t)clientSequence generation:(int32_t)generation effectiveNamespace:(NSString *)effectiveNamespace effectiveCanonicalId:(NSString *)effectiveCanonicalId valueCodecVersion:(int32_t)valueCodecVersion basePresence:(SMSMutationPresenceState *)basePresence baseBlob:(SMSKotlinByteArray * _Nullable)baseBlob minePresence:(SMSMutationPresenceState *)minePresence mineBlob:(SMSKotlinByteArray * _Nullable)mineBlob preconditionMetaPresent:(BOOL)preconditionMetaPresent preconditionWrittenAt:(SMSLong * _Nullable)preconditionWrittenAt preconditionEtag:(NSString * _Nullable)preconditionEtag advertisedRetiredThroughSequence:(int64_t)advertisedRetiredThroughSequence generationIdempotencyKey:(NSString *)generationIdempotencyKey preparedAt:(int64_t)preparedAt conflictMetaPresent:(SMSBoolean * _Nullable)conflictMetaPresent conflictWrittenAt:(SMSLong * _Nullable)conflictWrittenAt conflictEtag:(NSString * _Nullable)conflictEtag conflictReceivedAt:(SMSLong * _Nullable)conflictReceivedAt __attribute__((swift_name("init(clientId:clientSequence:generation:effectiveNamespace:effectiveCanonicalId:valueCodecVersion:basePresence:baseBlob:minePresence:mineBlob:preconditionMetaPresent:preconditionWrittenAt:preconditionEtag:advertisedRetiredThroughSequence:generationIdempotencyKey:preparedAt:conflictMetaPresent:conflictWrittenAt:conflictEtag:conflictReceivedAt:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationClientRecord"))) +@interface SMSMutationClientRecord : SMSBase +@property (readonly) NSString *clientId __attribute__((swift_name("clientId"))); +@property (readonly) int64_t createdAt __attribute__((swift_name("createdAt"))); +@property (readonly) int64_t lastAllocatedSequence __attribute__((swift_name("lastAllocatedSequence"))); +@property (readonly) int32_t recordVersion __attribute__((swift_name("recordVersion"))); +@property (readonly) int64_t retiredThroughSequence __attribute__((swift_name("retiredThroughSequence"))); +@property (readonly) int64_t serverConfirmedRetiredThroughSequence __attribute__((swift_name("serverConfirmedRetiredThroughSequence"))); +- (instancetype)initWithRecordVersion:(int32_t)recordVersion clientId:(NSString *)clientId lastAllocatedSequence:(int64_t)lastAllocatedSequence retiredThroughSequence:(int64_t)retiredThroughSequence serverConfirmedRetiredThroughSequence:(int64_t)serverConfirmedRetiredThroughSequence createdAt:(int64_t)createdAt __attribute__((swift_name("init(recordVersion:clientId:lastAllocatedSequence:retiredThroughSequence:serverConfirmedRetiredThroughSequence:createdAt:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationEffectDisposition"))) +@interface SMSMutationEffectDisposition : SMSKotlinEnum +@property (class, readonly) SMSMutationEffectDisposition *pending __attribute__((swift_name("pending"))); +@property (class, readonly) SMSMutationEffectDisposition *applied __attribute__((swift_name("applied"))); +@property (class, readonly) SMSMutationEffectDisposition *skipped __attribute__((swift_name("skipped"))); +@property (class, readonly) NSArray *entries __attribute__((swift_name("entries"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); +- (instancetype)initWithName:(NSString *)name ordinal:(int32_t)ordinal __attribute__((swift_name("init(name:ordinal:)"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable)); ++ (SMSKotlinArray *)values __attribute__((swift_name("values()"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationEffectKind"))) +@interface SMSMutationEffectKind : SMSKotlinEnum +@property (class, readonly) SMSMutationEffectKind *key __attribute__((swift_name("key"))); +@property (class, readonly) SMSMutationEffectKind *namespace_ __attribute__((swift_name("namespace_"))); +@property (class, readonly) NSArray *entries __attribute__((swift_name("entries"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); +- (instancetype)initWithName:(NSString *)name ordinal:(int32_t)ordinal __attribute__((swift_name("init(name:ordinal:)"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable)); ++ (SMSKotlinArray *)values __attribute__((swift_name("values()"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationEffectRecord"))) +@interface SMSMutationEffectRecord : SMSBase +@property (readonly) NSString * _Nullable canonicalId __attribute__((swift_name("canonicalId"))); +@property (readonly) NSString *clientId __attribute__((swift_name("clientId"))); +@property (readonly) int64_t clientSequence __attribute__((swift_name("clientSequence"))); +@property (readonly) SMSLong * _Nullable completedAt __attribute__((swift_name("completedAt"))); +@property (readonly) int64_t createdAt __attribute__((swift_name("createdAt"))); +@property (readonly) SMSMutationEffectDisposition *disposition __attribute__((swift_name("disposition"))); +@property (readonly) int32_t effectIndex __attribute__((swift_name("effectIndex"))); +@property (readonly) SMSMutationEffectKind *kind __attribute__((swift_name("kind"))); +@property (readonly, getter=namespace) NSString *namespace_ __attribute__((swift_name("namespace_"))); +- (instancetype)initWithClientId:(NSString *)clientId clientSequence:(int64_t)clientSequence effectIndex:(int32_t)effectIndex kind:(SMSMutationEffectKind *)kind namespace:(NSString *)namespace_ canonicalId:(NSString * _Nullable)canonicalId createdAt:(int64_t)createdAt disposition:(SMSMutationEffectDisposition *)disposition completedAt:(SMSLong * _Nullable)completedAt __attribute__((swift_name("init(clientId:clientSequence:effectIndex:kind:namespace:canonicalId:createdAt:disposition:completedAt:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationExecutionPhase"))) +@interface SMSMutationExecutionPhase : SMSKotlinEnum +@property (class, readonly) SMSMutationExecutionPhase *unprepared __attribute__((swift_name("unprepared"))); +@property (class, readonly) SMSMutationExecutionPhase *ready __attribute__((swift_name("ready"))); +@property (class, readonly) SMSMutationExecutionPhase *inflight __attribute__((swift_name("inflight"))); +@property (class, readonly) SMSMutationExecutionPhase *refreshRequired __attribute__((swift_name("refreshRequired"))); +@property (class, readonly) SMSMutationExecutionPhase *acked __attribute__((swift_name("acked"))); +@property (class, readonly) SMSMutationExecutionPhase *effectsPending __attribute__((swift_name("effectsPending"))); +@property (class, readonly) SMSMutationExecutionPhase *parked __attribute__((swift_name("parked"))); +@property (class, readonly) SMSMutationExecutionPhase *retired __attribute__((swift_name("retired"))); +@property (class, readonly) NSArray *entries __attribute__((swift_name("entries"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); +- (instancetype)initWithName:(NSString *)name ordinal:(int32_t)ordinal __attribute__((swift_name("init(name:ordinal:)"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable)); ++ (SMSKotlinArray *)values __attribute__((swift_name("values()"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationExecutionRecord"))) +@interface SMSMutationExecutionRecord : SMSBase +@property (readonly) SMSLong * _Nullable activeFailureId __attribute__((swift_name("activeFailureId"))); +@property (readonly) int32_t attempt __attribute__((swift_name("attempt"))); +@property (readonly) NSString *clientId __attribute__((swift_name("clientId"))); +@property (readonly) int64_t clientSequence __attribute__((swift_name("clientSequence"))); +@property (readonly) int32_t currentGeneration __attribute__((swift_name("currentGeneration"))); +@property (readonly) SMSLong * _Nullable lastAttemptAt __attribute__((swift_name("lastAttemptAt"))); +@property (readonly) SMSMutationExecutionPhase *phase __attribute__((swift_name("phase"))); +@property (readonly) SMSLong * _Nullable retiredAt __attribute__((swift_name("retiredAt"))); +- (instancetype)initWithClientId:(NSString *)clientId clientSequence:(int64_t)clientSequence phase:(SMSMutationExecutionPhase *)phase currentGeneration:(int32_t)currentGeneration attempt:(int32_t)attempt lastAttemptAt:(SMSLong * _Nullable)lastAttemptAt activeFailureId:(SMSLong * _Nullable)activeFailureId retiredAt:(SMSLong * _Nullable)retiredAt __attribute__((swift_name("init(clientId:clientSequence:phase:currentGeneration:attempt:lastAttemptAt:activeFailureId:retiredAt:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationFailureRecord"))) +@interface SMSMutationFailureRecord : SMSBase +@property (readonly) NSString *clientId __attribute__((swift_name("clientId"))); +@property (readonly) int64_t clientSequence __attribute__((swift_name("clientSequence"))); +@property (readonly) NSString *detail __attribute__((swift_name("detail"))); +@property (readonly) int64_t failureId __attribute__((swift_name("failureId"))); +@property (readonly) int32_t generation __attribute__((swift_name("generation"))); +@property (readonly) SMSMutationFailureKind *kind __attribute__((swift_name("kind"))); +@property (readonly) NSString *message __attribute__((swift_name("message"))); +@property (readonly) int64_t occurredAt __attribute__((swift_name("occurredAt"))); +- (instancetype)initWithFailureId:(int64_t)failureId clientId:(NSString *)clientId clientSequence:(int64_t)clientSequence generation:(int32_t)generation kind:(SMSMutationFailureKind *)kind detail:(NSString *)detail message:(NSString *)message occurredAt:(int64_t)occurredAt __attribute__((swift_name("init(failureId:clientId:clientSequence:generation:kind:detail:message:occurredAt:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationIntentRecord"))) +@interface SMSMutationIntentRecord : SMSBase +@property (readonly) SMSKotlinByteArray *argsBlob __attribute__((swift_name("argsBlob"))); +@property (readonly) NSString *canonicalId __attribute__((swift_name("canonicalId"))); +@property (readonly) NSString *clientId __attribute__((swift_name("clientId"))); +@property (readonly) int64_t clientSequence __attribute__((swift_name("clientSequence"))); +@property (readonly) int64_t createdAt __attribute__((swift_name("createdAt"))); +@property (readonly) NSString *idempotencyRoot __attribute__((swift_name("idempotencyRoot"))); +@property (readonly) NSString *mutationId __attribute__((swift_name("mutationId"))); +@property (readonly) NSString *mutatorId __attribute__((swift_name("mutatorId"))); +@property (readonly) int32_t mutatorVersion __attribute__((swift_name("mutatorVersion"))); +@property (readonly, getter=namespace) NSString *namespace_ __attribute__((swift_name("namespace_"))); +@property (readonly) int32_t recordVersion __attribute__((swift_name("recordVersion"))); +@property (readonly) int64_t rowId __attribute__((swift_name("rowId"))); +- (instancetype)initWithRowId:(int64_t)rowId recordVersion:(int32_t)recordVersion clientId:(NSString *)clientId clientSequence:(int64_t)clientSequence mutationId:(NSString *)mutationId namespace:(NSString *)namespace_ canonicalId:(NSString *)canonicalId mutatorId:(NSString *)mutatorId mutatorVersion:(int32_t)mutatorVersion argsBlob:(SMSKotlinByteArray *)argsBlob idempotencyRoot:(NSString *)idempotencyRoot createdAt:(int64_t)createdAt __attribute__((swift_name("init(rowId:recordVersion:clientId:clientSequence:mutationId:namespace:canonicalId:mutatorId:mutatorVersion:argsBlob:idempotencyRoot:createdAt:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("MutationJournalTransaction"))) +@protocol SMSMutationJournalTransaction +@required +- (NSArray *)acksClientId:(NSString *)clientId __attribute__((swift_name("acks(clientId:)"))); +- (void)advanceAliasRecord:(SMSMutationKeyAliasRecord *)record __attribute__((swift_name("advanceAlias(record:)"))); +- (void)advanceClientRecord:(SMSMutationClientRecord *)record __attribute__((swift_name("advanceClient(record:)"))); +- (void)advanceEffectRecord:(SMSMutationEffectRecord *)record __attribute__((swift_name("advanceEffect(record:)"))); +- (void)advanceExecutionRecord:(SMSMutationExecutionRecord *)record __attribute__((swift_name("advanceExecution(record:)"))); +- (void)advanceTombstoneRecord:(SMSMutationKeyTombstoneRecord *)record __attribute__((swift_name("advanceTombstone(record:)"))); +- (NSArray *)aliases __attribute__((swift_name("aliases()"))); +- (SMSMutationFailureRecord *)appendFailureClientId:(NSString *)clientId clientSequence:(int64_t)clientSequence generation:(int32_t)generation kind:(SMSMutationFailureKind *)kind detail:(NSString *)detail message:(NSString *)message occurredAt:(int64_t)occurredAt __attribute__((swift_name("appendFailure(clientId:clientSequence:generation:kind:detail:message:occurredAt:)"))); +- (NSArray *)attemptsClientId:(NSString *)clientId __attribute__((swift_name("attempts(clientId:)"))); +- (SMSMutationClientRecord * _Nullable)clientClientId:(NSString *)clientId __attribute__((swift_name("client(clientId:)"))); +- (SMSMutationClientRecord *)confirmRetiredThroughClientId:(NSString *)clientId requestedThroughSequence:(int64_t)requestedThroughSequence serverConfirmedThroughSequence:(int64_t)serverConfirmedThroughSequence __attribute__((swift_name("confirmRetiredThrough(clientId:requestedThroughSequence:serverConfirmedThroughSequence:)"))); +- (NSArray *)effectsClientId:(NSString *)clientId __attribute__((swift_name("effects(clientId:)"))); +- (NSArray *)executionsClientId:(NSString *)clientId __attribute__((swift_name("executions(clientId:)"))); +- (NSArray *)failuresClientId:(NSString *)clientId __attribute__((swift_name("failures(clientId:)"))); +- (void)insertAckRecord:(SMSMutationAckRecord *)record __attribute__((swift_name("insertAck(record:)"))); +- (void)insertAliasRecord:(SMSMutationKeyAliasRecord *)record __attribute__((swift_name("insertAlias(record:)"))); +- (void)insertAttemptRecord:(SMSMutationAttemptRecord *)record __attribute__((swift_name("insertAttempt(record:)"))); +- (void)insertClientRecord:(SMSMutationClientRecord *)record __attribute__((swift_name("insertClient(record:)"))); +- (void)insertEffectRecord:(SMSMutationEffectRecord *)record __attribute__((swift_name("insertEffect(record:)"))); +- (void)insertExecutionRecord:(SMSMutationExecutionRecord *)record __attribute__((swift_name("insertExecution(record:)"))); +- (SMSMutationIntentRecord *)insertIntentRecordVersion:(int32_t)recordVersion clientId:(NSString *)clientId clientSequence:(int64_t)clientSequence mutationId:(NSString *)mutationId namespace:(NSString *)namespace_ canonicalId:(NSString *)canonicalId mutatorId:(NSString *)mutatorId mutatorVersion:(int32_t)mutatorVersion argsBlob:(SMSKotlinByteArray *)argsBlob idempotencyRoot:(NSString *)idempotencyRoot createdAt:(int64_t)createdAt __attribute__((swift_name("insertIntent(recordVersion:clientId:clientSequence:mutationId:namespace:canonicalId:mutatorId:mutatorVersion:argsBlob:idempotencyRoot:createdAt:)"))); +- (void)insertTombstoneRecord:(SMSMutationKeyTombstoneRecord *)record __attribute__((swift_name("insertTombstone(record:)"))); +- (NSArray *)intentsClientId:(NSString *)clientId __attribute__((swift_name("intents(clientId:)"))); +- (void)pruneClientId:(NSString *)clientId serverConfirmedRetiredThroughSequence:(int64_t)serverConfirmedRetiredThroughSequence __attribute__((swift_name("prune(clientId:serverConfirmedRetiredThroughSequence:)"))); +- (void)recordConflictReceiptRecord:(SMSMutationAttemptRecord *)record __attribute__((swift_name("recordConflictReceipt(record:)"))); +- (NSArray *)tombstones __attribute__((swift_name("tombstones()"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationKeyAliasRecord"))) +@interface SMSMutationKeyAliasRecord : SMSBase +@property (readonly) SMSLong * _Nullable activatedAt __attribute__((swift_name("activatedAt"))); +@property (readonly) int64_t createdAt __attribute__((swift_name("createdAt"))); +@property (readonly) NSString *createdByClientId __attribute__((swift_name("createdByClientId"))); +@property (readonly) int64_t createdBySequence __attribute__((swift_name("createdBySequence"))); +@property (readonly) NSString *sourceCanonicalId __attribute__((swift_name("sourceCanonicalId"))); +@property (readonly) NSString *sourceNamespace __attribute__((swift_name("sourceNamespace"))); +@property (readonly) SMSMutationAliasState *state __attribute__((swift_name("state"))); +@property (readonly) NSString *targetCanonicalId __attribute__((swift_name("targetCanonicalId"))); +@property (readonly) NSString *targetNamespace __attribute__((swift_name("targetNamespace"))); +- (instancetype)initWithSourceNamespace:(NSString *)sourceNamespace sourceCanonicalId:(NSString *)sourceCanonicalId targetNamespace:(NSString *)targetNamespace targetCanonicalId:(NSString *)targetCanonicalId state:(SMSMutationAliasState *)state createdByClientId:(NSString *)createdByClientId createdBySequence:(int64_t)createdBySequence createdAt:(int64_t)createdAt activatedAt:(SMSLong * _Nullable)activatedAt __attribute__((swift_name("init(sourceNamespace:sourceCanonicalId:targetNamespace:targetCanonicalId:state:createdByClientId:createdBySequence:createdAt:activatedAt:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationKeyTombstoneRecord"))) +@interface SMSMutationKeyTombstoneRecord : SMSBase +@property (readonly) SMSLong * _Nullable activatedAt __attribute__((swift_name("activatedAt"))); +@property (readonly) NSString *canonicalId __attribute__((swift_name("canonicalId"))); +@property (readonly) int64_t createdAt __attribute__((swift_name("createdAt"))); +@property (readonly) NSString *createdByClientId __attribute__((swift_name("createdByClientId"))); +@property (readonly) int64_t createdBySequence __attribute__((swift_name("createdBySequence"))); +@property (readonly, getter=namespace) NSString *namespace_ __attribute__((swift_name("namespace_"))); +@property (readonly) SMSMutationTombstoneState *state __attribute__((swift_name("state"))); +@property (readonly) SMSLong * _Nullable supersededAt __attribute__((swift_name("supersededAt"))); +@property (readonly) NSString * _Nullable supersededByClientId __attribute__((swift_name("supersededByClientId"))); +@property (readonly) SMSLong * _Nullable supersededBySequence __attribute__((swift_name("supersededBySequence"))); +- (instancetype)initWithNamespace:(NSString *)namespace_ canonicalId:(NSString *)canonicalId createdByClientId:(NSString *)createdByClientId createdBySequence:(int64_t)createdBySequence state:(SMSMutationTombstoneState *)state createdAt:(int64_t)createdAt activatedAt:(SMSLong * _Nullable)activatedAt supersededByClientId:(NSString * _Nullable)supersededByClientId supersededBySequence:(SMSLong * _Nullable)supersededBySequence supersededAt:(SMSLong * _Nullable)supersededAt __attribute__((swift_name("init(namespace:canonicalId:createdByClientId:createdBySequence:state:createdAt:activatedAt:supersededByClientId:supersededBySequence:supersededAt:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationTombstoneState"))) +@interface SMSMutationTombstoneState : SMSKotlinEnum +@property (class, readonly) SMSMutationTombstoneState *pending __attribute__((swift_name("pending"))); +@property (class, readonly) SMSMutationTombstoneState *active __attribute__((swift_name("active"))); +@property (class, readonly) SMSMutationTombstoneState *superseded __attribute__((swift_name("superseded"))); +@property (class, readonly) NSArray *entries __attribute__((swift_name("entries"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); +- (instancetype)initWithName:(NSString *)name ordinal:(int32_t)ordinal __attribute__((swift_name("init(name:ordinal:)"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable)); ++ (SMSKotlinArray *)values __attribute__((swift_name("values()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutationStoreKt"))) +@interface SMSMutationStoreKt : SMSBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ ++ (SMSMutationStore, id> *)mutationStoreRegistry:(SMSMutatorRegistry, id> *)registry server:(id)server keyResolver:(id)keyResolver valueCodecVersion:(int32_t)valueCodecVersion valueCodec:(id)valueCodec configure:(void (^)(SMSMutationStoreBuilder, id> *))configure __attribute__((swift_name("mutationStore(registry:server:keyResolver:valueCodecVersion:valueCodec:configure:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("MutatorRegistryKt"))) +@interface SMSMutatorRegistryKt : SMSBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ ++ (SMSMutatorRegistry, id> *)mutatorRegistryConfigure:(void (^)(SMSMutatorRegistryBuilder, id> *))configure __attribute__((swift_name("mutatorRegistry(configure:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("__SkieSuspendWrappersKt"))) +@interface SMS__SkieSuspendWrappersKt : SMSBase ++ (void)Skie_Suspend__0__resolveDispatchReceiver:(id)dispatchReceiver identity:(SMSMutationKeyIdentity *)identity suspendHandler:(SMSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__0__resolve(dispatchReceiver:identity:suspendHandler:)"))); ++ (void)Skie_Suspend__10__emitDispatchReceiver:(id)dispatchReceiver value:(id _Nullable)value suspendHandler:(SMSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__10__emit(dispatchReceiver:value:suspendHandler:)"))); ++ (void)Skie_Suspend__11__clearDispatchReceiver:(id)dispatchReceiver key:(id)key suspendHandler:(SMSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__11__clear(dispatchReceiver:key:suspendHandler:)"))); ++ (void)Skie_Suspend__12__clearAllDispatchReceiver:(id)dispatchReceiver suspendHandler:(SMSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__12__clearAll(dispatchReceiver:suspendHandler:)"))); ++ (void)Skie_Suspend__13__clearNamespaceDispatchReceiver:(id)dispatchReceiver namespace:(SMSStore6_coreStoreNamespace *)namespace_ suspendHandler:(SMSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__13__clearNamespace(dispatchReceiver:namespace:suspendHandler:)"))); ++ (void)Skie_Suspend__14__getDispatchReceiver:(id)dispatchReceiver key:(id)key freshness:(id)freshness suspendHandler:(SMSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__14__get(dispatchReceiver:key:freshness:suspendHandler:)"))); ++ (void)Skie_Suspend__15__invalidateDispatchReceiver:(id)dispatchReceiver key:(id)key suspendHandler:(SMSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__15__invalidate(dispatchReceiver:key:suspendHandler:)"))); ++ (void)Skie_Suspend__16__invalidateAllDispatchReceiver:(id)dispatchReceiver suspendHandler:(SMSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__16__invalidateAll(dispatchReceiver:suspendHandler:)"))); ++ (void)Skie_Suspend__17__invalidateNamespaceDispatchReceiver:(id)dispatchReceiver namespace:(SMSStore6_coreStoreNamespace *)namespace_ suspendHandler:(SMSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__17__invalidateNamespace(dispatchReceiver:namespace:suspendHandler:)"))); ++ (void)Skie_Suspend__18__deleteDispatchReceiver:(id)dispatchReceiver key:(id)key suspendHandler:(SMSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__18__delete(dispatchReceiver:key:suspendHandler:)"))); ++ (void)Skie_Suspend__19__deleteAllDispatchReceiver:(id)dispatchReceiver suspendHandler:(SMSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__19__deleteAll(dispatchReceiver:suspendHandler:)"))); ++ (void)Skie_Suspend__1__pushDispatchReceiver:(id)dispatchReceiver request:(SMSMutationPush, id> *)request suspendHandler:(SMSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__1__push(dispatchReceiver:request:suspendHandler:)"))); ++ (void)Skie_Suspend__20__deleteNamespaceDispatchReceiver:(id)dispatchReceiver namespace:(SMSStore6_coreStoreNamespace *)namespace_ suspendHandler:(SMSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__20__deleteNamespace(dispatchReceiver:namespace:suspendHandler:)"))); ++ (void)Skie_Suspend__21__writeDispatchReceiver:(id)dispatchReceiver key:(id)key value:(id)value suspendHandler:(SMSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__21__write(dispatchReceiver:key:value:suspendHandler:)"))); ++ (void)Skie_Suspend__22__transactionDispatchReceiver:(id)dispatchReceiver block:(id _Nullable (^)(id))block suspendHandler:(SMSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__22__transaction(dispatchReceiver:block:suspendHandler:)"))); ++ (void)Skie_Suspend__23__invokeDispatchReceiver:(id)dispatchReceiver p1:(id _Nullable)p1 suspendHandler:(SMSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__23__invoke(dispatchReceiver:p1:suspendHandler:)"))); ++ (void)Skie_Suspend__24__fetchDispatchReceiver:(id)dispatchReceiver key:(id)key etag:(NSString * _Nullable)etag suspendHandler:(SMSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__24__fetch(dispatchReceiver:key:etag:suspendHandler:)"))); ++ (void)Skie_Suspend__25__advanceGlobalStaleWatermarkDispatchReceiver:(id)dispatchReceiver suspendHandler:(SMSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__25__advanceGlobalStaleWatermark(dispatchReceiver:suspendHandler:)"))); ++ (void)Skie_Suspend__26__advanceStaleWatermarkDispatchReceiver:(id)dispatchReceiver namespace:(SMSStore6_coreStoreNamespace *)namespace_ suspendHandler:(SMSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__26__advanceStaleWatermark(dispatchReceiver:namespace:suspendHandler:)"))); ++ (void)Skie_Suspend__27__forgetDispatchReceiver:(id)dispatchReceiver key:(id)key suspendHandler:(SMSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__27__forget(dispatchReceiver:key:suspendHandler:)"))); ++ (void)Skie_Suspend__28__forgetAllDispatchReceiver:(id)dispatchReceiver suspendHandler:(SMSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__28__forgetAll(dispatchReceiver:suspendHandler:)"))); ++ (void)Skie_Suspend__29__forgetNamespaceDispatchReceiver:(id)dispatchReceiver namespace:(SMSStore6_coreStoreNamespace *)namespace_ suspendHandler:(SMSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__29__forgetNamespace(dispatchReceiver:namespace:suspendHandler:)"))); ++ (void)Skie_Suspend__2__retireDispatchReceiver:(id)dispatchReceiver request:(SMSMutationRetirement *)request suspendHandler:(SMSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__2__retire(dispatchReceiver:request:suspendHandler:)"))); ++ (void)Skie_Suspend__30__markStaleDispatchReceiver:(id)dispatchReceiver key:(id)key suspendHandler:(SMSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__30__markStale(dispatchReceiver:key:suspendHandler:)"))); ++ (void)Skie_Suspend__31__recordFailureDispatchReceiver:(id)dispatchReceiver key:(id)key atEpochMillis:(int64_t)atEpochMillis suspendHandler:(SMSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__31__recordFailure(dispatchReceiver:key:atEpochMillis:suspendHandler:)"))); ++ (void)Skie_Suspend__32__recordSuccessDispatchReceiver:(id)dispatchReceiver key:(id)key meta:(id)meta suspendHandler:(SMSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__32__recordSuccess(dispatchReceiver:key:meta:suspendHandler:)"))); ++ (void)Skie_Suspend__33__statusDispatchReceiver:(id)dispatchReceiver key:(id)key suspendHandler:(SMSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__33__status(dispatchReceiver:key:suspendHandler:)"))); ++ (void)Skie_Suspend__34__hasNextDispatchReceiver:(SMSSkieColdFlowIterator *)dispatchReceiver suspendHandler:(SMSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__34__hasNext(dispatchReceiver:suspendHandler:)"))); ++ (void)Skie_Suspend__3__deadLettersDispatchReceiver:(SMSMutationStore, id> *)dispatchReceiver suspendHandler:(SMSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__3__deadLetters(dispatchReceiver:suspendHandler:)"))); ++ (void)Skie_Suspend__4__drainDispatchReceiver:(SMSMutationStore, id> *)dispatchReceiver key:(id)key suspendHandler:(SMSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__4__drain(dispatchReceiver:key:suspendHandler:)"))); ++ (void)Skie_Suspend__5__drainDispatchReceiver:(SMSMutationStore, id> *)dispatchReceiver suspendHandler:(SMSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__5__drain(dispatchReceiver:suspendHandler:)"))); ++ (void)Skie_Suspend__6__mutateDispatchReceiver:(SMSMutationStore, id> *)dispatchReceiver key:(id)key ref:(SMSMutatorRef, id, id> *)ref args:(id)args suspendHandler:(SMSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__6__mutate(dispatchReceiver:key:ref:args:suspendHandler:)"))); ++ (void)Skie_Suspend__7__pendingDispatchReceiver:(SMSMutationStore, id> *)dispatchReceiver key:(id)key suspendHandler:(SMSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__7__pending(dispatchReceiver:key:suspendHandler:)"))); ++ (void)Skie_Suspend__8__pendingWritesDispatchReceiver:(SMSMutationStore, id> *)dispatchReceiver suspendHandler:(SMSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__8__pendingWrites(dispatchReceiver:suspendHandler:)"))); ++ (void)Skie_Suspend__9__collectDispatchReceiver:(id)dispatchReceiver collector:(id)collector suspendHandler:(SMSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__9__collect(dispatchReceiver:collector:suspendHandler:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("__SkieTypeExportsKt"))) +@interface SMS__SkieTypeExportsKt : SMSBase ++ (void)skieTypeExports_0P0:(SMSStore6_coreFreshnessCachedOrFetch *)p0 p1:(SMSStore6_coreFreshnessLocalOnly *)p1 p2:(SMSStore6_coreFreshnessMaxAge *)p2 p3:(SMSStore6_coreFreshnessMustBeFresh *)p3 p4:(SMSStore6_coreFreshnessStaleIfError *)p4 p5:(SMSStore6_coreStoreErrorConflict *)p5 p6:(SMSStore6_coreStoreErrorConversion *)p6 p7:(SMSStore6_coreStoreErrorFetch *)p7 p8:(SMSStore6_coreStoreErrorFreshnessUnsatisfiable *)p8 p9:(SMSStore6_coreStoreErrorMissing *)p9 p10:(SMSStore6_coreStoreErrorPersistence *)p10 p11:(id)p11 p12:(SMSStore6_coreFetchPlanConditional *)p12 p13:(SMSStore6_coreFetchPlanFetch *)p13 p14:(SMSStore6_coreFetchPlanSkip *)p14 p15:(SMSStore6_coreFetcherResultDeleted *)p15 p16:(SMSStore6_coreFetcherResultError *)p16 p17:(SMSStore6_coreFetcherResultNotModified *)p17 p18:(SMSStore6_coreFetcherResultSuccess *)p18 p19:(SMSStore6_coreKeyEvents *)p19 __attribute__((swift_name("skieTypeExports_0(p0:p1:p2:p3:p4:p5:p6:p7:p8:p9:p10:p11:p12:p13:p14:p15:p16:p17:p18:p19:)"))); ++ (void)skieTypeExports_1P0:(SMSStore6_coreStoreResultData *)p0 p1:(SMSStore6_coreStoreResultError *)p1 p2:(SMSStore6_coreStoreResultLoading *)p2 p3:(SMSStore6_coreStoreResultRevalidated *)p3 __attribute__((swift_name("skieTypeExports_1(p0:p1:p2:p3:)"))); +@end + +__attribute__((swift_name("KotlinThrowable"))) +@interface SMSKotlinThrowable : SMSBase +@property (readonly) SMSKotlinThrowable * _Nullable cause __attribute__((swift_name("cause"))); +@property (readonly) NSString * _Nullable message __attribute__((swift_name("message"))); +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); ++ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); +- (instancetype)initWithMessage:(NSString * _Nullable)message __attribute__((swift_name("init(message:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithCause:(SMSKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithMessage:(NSString * _Nullable)message cause:(SMSKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(message:cause:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note annotations + * kotlin.experimental.ExperimentalNativeApi +*/ +- (SMSKotlinArray *)getStackTrace __attribute__((swift_name("getStackTrace()"))); +- (void)printStackTrace __attribute__((swift_name("printStackTrace()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +- (NSError *)asError __attribute__((swift_name("asError()"))); +@end + +__attribute__((swift_name("KotlinException"))) +@interface SMSKotlinException : SMSKotlinThrowable +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); ++ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); +- (instancetype)initWithMessage:(NSString * _Nullable)message __attribute__((swift_name("init(message:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithCause:(SMSKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithMessage:(NSString * _Nullable)message cause:(SMSKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(message:cause:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((swift_name("KotlinRuntimeException"))) +@interface SMSKotlinRuntimeException : SMSKotlinException +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); ++ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); +- (instancetype)initWithMessage:(NSString * _Nullable)message __attribute__((swift_name("init(message:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithCause:(SMSKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithMessage:(NSString * _Nullable)message cause:(SMSKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(message:cause:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((swift_name("KotlinIllegalStateException"))) +@interface SMSKotlinIllegalStateException : SMSKotlinRuntimeException +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); ++ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); +- (instancetype)initWithMessage:(NSString * _Nullable)message __attribute__((swift_name("init(message:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithCause:(SMSKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithMessage:(NSString * _Nullable)message cause:(SMSKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(message:cause:)"))) __attribute__((objc_designated_initializer)); +@end + + +/** + * @note annotations + * kotlin.SinceKotlin(version="1.4") +*/ +__attribute__((swift_name("KotlinCancellationException"))) +@interface SMSKotlinCancellationException : SMSKotlinIllegalStateException +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); ++ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); +- (instancetype)initWithMessage:(NSString * _Nullable)message __attribute__((swift_name("init(message:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithCause:(SMSKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithMessage:(NSString * _Nullable)message cause:(SMSKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(message:cause:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((swift_name("Kotlinx_coroutines_coreRunnable"))) +@protocol SMSKotlinx_coroutines_coreRunnable +@required +- (void)run __attribute__((swift_name("run()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("KotlinByteArray"))) +@interface SMSKotlinByteArray : SMSBase +@property (readonly) int32_t size __attribute__((swift_name("size"))); ++ (instancetype)arrayWithSize:(int32_t)size __attribute__((swift_name("init(size:)"))); ++ (instancetype)arrayWithSize:(int32_t)size init:(SMSByte *(^)(SMSInt *))init __attribute__((swift_name("init(size:init:)"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); +- (int8_t)getIndex:(int32_t)index __attribute__((swift_name("get(index:)"))); +- (SMSKotlinByteIterator *)iterator __attribute__((swift_name("iterator()"))); +- (void)setIndex:(int32_t)index value:(int8_t)value __attribute__((swift_name("set(index:value:)"))); +@end + +__attribute__((swift_name("Store6_coreStoreMeta"))) +@protocol SMSStore6_coreStoreMeta +@required +@property (readonly) NSString * _Nullable etag __attribute__((swift_name("etag"))); +@property (readonly) int64_t writtenAtEpochMillis __attribute__((swift_name("writtenAtEpochMillis"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("KotlinEnumCompanion"))) +@interface SMSKotlinEnumCompanion : SMSBase +@property (class, readonly, getter=shared) SMSKotlinEnumCompanion *shared __attribute__((swift_name("shared"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)companion __attribute__((swift_name("init()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("KotlinArray"))) +@interface SMSKotlinArray : SMSBase +@property (readonly) int32_t size __attribute__((swift_name("size"))); ++ (instancetype)arrayWithSize:(int32_t)size init:(T _Nullable (^)(SMSInt *))init __attribute__((swift_name("init(size:init:)"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); +- (T _Nullable)getIndex:(int32_t)index __attribute__((swift_name("get(index:)"))); +- (id)iterator __attribute__((swift_name("iterator()"))); +- (void)setIndex:(int32_t)index value:(T _Nullable)value __attribute__((swift_name("set(index:value:)"))); +@end + +__attribute__((swift_name("Store6_coreStoreKey"))) +@protocol SMSStore6_coreStoreKey +@required +- (NSString *)canonicalId __attribute__((swift_name("canonicalId()"))); +@property (readonly, getter=namespace) SMSStore6_coreStoreNamespace *namespace_ __attribute__((swift_name("namespace_"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Store6_coreStoreNamespace"))) +@interface SMSStore6_coreStoreNamespace : SMSBase +@property (readonly) NSString *value __attribute__((swift_name("value"))); +- (instancetype)initWithValue:(NSString *)value __attribute__((swift_name("init(value:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((swift_name("Store6_coreFreshness"))) +@protocol SMSStore6_coreFreshness +@required +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("Store6_coreBookkeeper"))) +@protocol SMSStore6_coreBookkeeper +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)advanceGlobalStaleWatermarkWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("advanceGlobalStaleWatermark(completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)advanceStaleWatermarkNamespace:(SMSStore6_coreStoreNamespace *)namespace_ completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("advanceStaleWatermark(namespace:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)forgetKey:(id)key completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("forget(key:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)forgetAllWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("forgetAll(completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)forgetNamespaceNamespace:(SMSStore6_coreStoreNamespace *)namespace_ completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("forgetNamespace(namespace:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)markStaleKey:(id)key completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("markStale(key:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)recordFailureKey:(id)key atEpochMillis:(int64_t)atEpochMillis completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("recordFailure(key:atEpochMillis:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)recordSuccessKey:(id)key meta:(id)meta completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("recordSuccess(key:meta:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)statusKey:(id)key completionHandler:(void (^)(SMSStore6_coreKeyStatus * _Nullable_result, NSError * _Nullable))completionHandler __attribute__((swift_name("status(key:completionHandler:)"))); +@end + +__attribute__((swift_name("KotlinFunction"))) +@protocol SMSKotlinFunction +@required +@end + +__attribute__((swift_name("KotlinSuspendFunction1"))) +@protocol SMSKotlinSuspendFunction1 +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)invokeP1:(id _Nullable)p1 completionHandler:(void (^)(id _Nullable_result, NSError * _Nullable))completionHandler __attribute__((swift_name("invoke(p1:completionHandler:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("Store6_coreFetcher"))) +@protocol SMSStore6_coreFetcher +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)fetchKey:(id)key etag:(NSString * _Nullable)etag completionHandler:(void (^)(id _Nullable, NSError * _Nullable))completionHandler __attribute__((swift_name("fetch(key:etag:completionHandler:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("Store6_coreFreshnessValidator"))) +@protocol SMSStore6_coreFreshnessValidator +@required +- (id)planContext:(SMSStore6_coreFreshnessContext *)context __attribute__((swift_name("plan(context:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("Store6_coreSourceOfTruth"))) +@protocol SMSStore6_coreSourceOfTruth +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)deleteKey:(id)key completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("delete(key:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)deleteAllWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("deleteAll(completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)deleteNamespaceNamespace:(SMSStore6_coreStoreNamespace *)namespace_ completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("deleteNamespace(namespace:completionHandler:)"))); +- (id)readerKey:(id)key __attribute__((swift_name("reader(key:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)writeKey:(id)key value:(id)value completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("write(key:value:completionHandler:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("Store6_coreStoreTelemetry"))) +@protocol SMSStore6_coreStoreTelemetry +@required +- (void)onClearedKey:(id)key __attribute__((swift_name("onCleared(key:)"))); +- (void)onFetchFailedKey:(id)key error:(SMSStore6_coreStoreError *)error duration:(int64_t)duration __attribute__((swift_name("onFetchFailed(key:error:duration:)"))); +- (void)onFetchStartedKey:(id)key __attribute__((swift_name("onFetchStarted(key:)"))); +- (void)onFetchSucceededKey:(id)key duration:(int64_t)duration __attribute__((swift_name("onFetchSucceeded(key:duration:)"))); +- (void)onInvalidatedKey:(id)key __attribute__((swift_name("onInvalidated(key:)"))); +- (void)onServeKey:(id)key origin:(SMSStore6_coreOrigin *)origin __attribute__((swift_name("onServe(key:origin:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("Store6_coreWallClock"))) +@protocol SMSStore6_coreWallClock +@required +- (int64_t)nowEpochMillis __attribute__((swift_name("nowEpochMillis()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("KotlinUnit"))) +@interface SMSKotlinUnit : SMSBase +@property (class, readonly, getter=shared) SMSKotlinUnit *shared __attribute__((swift_name("shared"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)unit __attribute__((swift_name("init()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Store6_coreFreshnessCachedOrFetch"))) +@interface SMSStore6_coreFreshnessCachedOrFetch : SMSBase +@property (class, readonly, getter=shared) SMSStore6_coreFreshnessCachedOrFetch *shared __attribute__((swift_name("shared"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)cachedOrFetch __attribute__((swift_name("init()"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Store6_coreFreshnessLocalOnly"))) +@interface SMSStore6_coreFreshnessLocalOnly : SMSBase +@property (class, readonly, getter=shared) SMSStore6_coreFreshnessLocalOnly *shared __attribute__((swift_name("shared"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)localOnly __attribute__((swift_name("init()"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Store6_coreFreshnessMaxAge"))) +@interface SMSStore6_coreFreshnessMaxAge : SMSBase +@property (readonly) int64_t notOlderThan __attribute__((swift_name("notOlderThan"))); +- (instancetype)initWithNotOlderThan:(int64_t)notOlderThan __attribute__((swift_name("init(notOlderThan:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Store6_coreFreshnessMustBeFresh"))) +@interface SMSStore6_coreFreshnessMustBeFresh : SMSBase +@property (class, readonly, getter=shared) SMSStore6_coreFreshnessMustBeFresh *shared __attribute__((swift_name("shared"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)mustBeFresh __attribute__((swift_name("init()"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Store6_coreFreshnessStaleIfError"))) +@interface SMSStore6_coreFreshnessStaleIfError : SMSBase +@property (class, readonly, getter=shared) SMSStore6_coreFreshnessStaleIfError *shared __attribute__((swift_name("shared"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)staleIfError __attribute__((swift_name("init()"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + +__attribute__((swift_name("Store6_coreStoreError"))) +@interface SMSStore6_coreStoreError : SMSBase +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Store6_coreStoreError.Conflict"))) +@interface SMSStore6_coreStoreErrorConflict : SMSStore6_coreStoreError +@property (readonly) NSString *message __attribute__((swift_name("message"))); +@property (readonly) id _Nullable serverMeta __attribute__((swift_name("serverMeta"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Store6_coreStoreError.Conversion"))) +@interface SMSStore6_coreStoreErrorConversion : SMSStore6_coreStoreError +@property (readonly) SMSKotlinThrowable * _Nullable cause __attribute__((swift_name("cause"))); +@property (readonly) NSString *message __attribute__((swift_name("message"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Store6_coreStoreError.Fetch"))) +@interface SMSStore6_coreStoreErrorFetch : SMSStore6_coreStoreError +@property (readonly) SMSKotlinThrowable * _Nullable cause __attribute__((swift_name("cause"))); +@property (readonly) NSString *message __attribute__((swift_name("message"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Store6_coreStoreError.FreshnessUnsatisfiable"))) +@interface SMSStore6_coreStoreErrorFreshnessUnsatisfiable : SMSStore6_coreStoreError +@property (readonly) NSString *message __attribute__((swift_name("message"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Store6_coreStoreError.Missing"))) +@interface SMSStore6_coreStoreErrorMissing : SMSStore6_coreStoreError +@property (readonly) id key __attribute__((swift_name("key"))); +@property (readonly) NSString *message __attribute__((swift_name("message"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Store6_coreStoreError.Persistence"))) +@interface SMSStore6_coreStoreErrorPersistence : SMSStore6_coreStoreError +@property (readonly) SMSKotlinThrowable * _Nullable cause __attribute__((swift_name("cause"))); +@property (readonly) NSString *message __attribute__((swift_name("message"))); +@end + +__attribute__((swift_name("Store6_coreStoreResult"))) +@protocol SMSStore6_coreStoreResult +@required +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((swift_name("Store6_coreFetchPlan"))) +@protocol SMSStore6_coreFetchPlan +@required +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Store6_coreFetchPlanConditional"))) +@interface SMSStore6_coreFetchPlanConditional : SMSBase +@property (readonly) NSString *etag __attribute__((swift_name("etag"))); +@property (readonly) BOOL servesResidentWhileFetching __attribute__((swift_name("servesResidentWhileFetching"))); +- (instancetype)initWithEtag:(NSString *)etag servesResidentWhileFetching:(BOOL)servesResidentWhileFetching __attribute__((swift_name("init(etag:servesResidentWhileFetching:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Store6_coreFetchPlanFetch"))) +@interface SMSStore6_coreFetchPlanFetch : SMSBase +@property (readonly) BOOL servesResidentWhileFetching __attribute__((swift_name("servesResidentWhileFetching"))); +- (instancetype)initWithServesResidentWhileFetching:(BOOL)servesResidentWhileFetching __attribute__((swift_name("init(servesResidentWhileFetching:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Store6_coreFetchPlanSkip"))) +@interface SMSStore6_coreFetchPlanSkip : SMSBase +@property (class, readonly, getter=shared) SMSStore6_coreFetchPlanSkip *shared __attribute__((swift_name("shared"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)skip __attribute__((swift_name("init()"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + +__attribute__((swift_name("Store6_coreFetcherResult"))) +@protocol SMSStore6_coreFetcherResult +@required +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Store6_coreFetcherResultDeleted"))) +@interface SMSStore6_coreFetcherResultDeleted : SMSBase +@property (class, readonly, getter=shared) SMSStore6_coreFetcherResultDeleted *shared __attribute__((swift_name("shared"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)deleted __attribute__((swift_name("init()"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Store6_coreFetcherResultError"))) +@interface SMSStore6_coreFetcherResultError : SMSBase +@property (readonly) SMSKotlinThrowable *cause __attribute__((swift_name("cause"))); +- (instancetype)initWithCause:(SMSKotlinThrowable *)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Store6_coreFetcherResultNotModified"))) +@interface SMSStore6_coreFetcherResultNotModified : SMSBase +@property (readonly) NSString * _Nullable etag __attribute__((swift_name("etag"))); +- (instancetype)initWithEtag:(NSString * _Nullable)etag __attribute__((swift_name("init(etag:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Store6_coreFetcherResultSuccess"))) +@interface SMSStore6_coreFetcherResultSuccess : SMSBase +@property (readonly) NSString * _Nullable etag __attribute__((swift_name("etag"))); +@property (readonly) V value __attribute__((swift_name("value"))); +- (instancetype)initWithValue:(V)value etag:(NSString * _Nullable)etag __attribute__((swift_name("init(value:etag:)"))) __attribute__((objc_designated_initializer)); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((swift_name("Store6_coreKeyEvents"))) +@interface SMSStore6_coreKeyEvents : SMSBase +@property (readonly) id key __attribute__((swift_name("key"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Store6_coreStoreResultData"))) +@interface SMSStore6_coreStoreResultData : SMSBase +@property (readonly) int64_t age __attribute__((swift_name("age"))); +@property (readonly) BOOL isStale __attribute__((swift_name("isStale"))); +@property (readonly) SMSStore6_coreOrigin *origin __attribute__((swift_name("origin"))); +@property (readonly) BOOL refreshing __attribute__((swift_name("refreshing"))); +@property (readonly) V _Nullable value __attribute__((swift_name("value"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Store6_coreStoreResultError"))) +@interface SMSStore6_coreStoreResultError : SMSBase +@property (readonly) SMSStore6_coreStoreError *error __attribute__((swift_name("error"))); +@property (readonly) BOOL servedStale __attribute__((swift_name("servedStale"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Store6_coreStoreResultLoading"))) +@interface SMSStore6_coreStoreResultLoading : SMSBase +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Store6_coreStoreResultRevalidated"))) +@interface SMSStore6_coreStoreResultRevalidated : SMSBase +@property (readonly) int64_t age __attribute__((swift_name("age"))); +@end + +__attribute__((swift_name("KotlinIterator"))) +@protocol SMSKotlinIterator +@required +- (BOOL)hasNext __attribute__((swift_name("hasNext()"))); +- (id _Nullable)next __attribute__((swift_name("next()"))); +@end + +__attribute__((swift_name("KotlinByteIterator"))) +@interface SMSKotlinByteIterator : SMSBase +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); ++ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); +- (SMSByte *)next __attribute__((swift_name("next()"))); +- (int8_t)nextByte __attribute__((swift_name("nextByte()"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Store6_coreKeyStatus"))) +@interface SMSStore6_coreKeyStatus : SMSBase +@property (readonly) int32_t consecutiveFailures __attribute__((swift_name("consecutiveFailures"))); +@property (readonly) BOOL durablyStale __attribute__((swift_name("durablyStale"))); +@property (readonly) SMSLong * _Nullable lastFailureAtEpochMillis __attribute__((swift_name("lastFailureAtEpochMillis"))); +@property (readonly) SMSLong * _Nullable lastSuccessSequence __attribute__((swift_name("lastSuccessSequence"))); +@property (readonly) id _Nullable meta __attribute__((swift_name("meta"))); +- (instancetype)initWithMeta:(id _Nullable)meta lastSuccessSequence:(SMSLong * _Nullable)lastSuccessSequence lastFailureAtEpochMillis:(SMSLong * _Nullable)lastFailureAtEpochMillis consecutiveFailures:(int32_t)consecutiveFailures durablyStale:(BOOL)durablyStale __attribute__((swift_name("init(meta:lastSuccessSequence:lastFailureAtEpochMillis:consecutiveFailures:durablyStale:)"))) __attribute__((objc_designated_initializer)); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Store6_coreFreshnessContext"))) +@interface SMSStore6_coreFreshnessContext : SMSBase +@property (readonly) BOOL epochStale __attribute__((swift_name("epochStale"))); +@property (readonly) id freshness __attribute__((swift_name("freshness"))); +@property (readonly) BOOL hasResidentValue __attribute__((swift_name("hasResidentValue"))); +@property (readonly) id _Nullable meta __attribute__((swift_name("meta"))); +@property (readonly) int64_t nowEpochMillis __attribute__((swift_name("nowEpochMillis"))); +@property (readonly) SMSStore6_coreKeyStatus * _Nullable status __attribute__((swift_name("status"))); +- (instancetype)initWithHasResidentValue:(BOOL)hasResidentValue meta:(id _Nullable)meta epochStale:(BOOL)epochStale freshness:(id)freshness nowEpochMillis:(int64_t)nowEpochMillis status:(SMSStore6_coreKeyStatus * _Nullable)status __attribute__((swift_name("init(hasResidentValue:meta:epochStale:freshness:nowEpochMillis:status:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Store6_coreOrigin"))) +@interface SMSStore6_coreOrigin : SMSKotlinEnum +@property (class, readonly) SMSStore6_coreOrigin *memory __attribute__((swift_name("memory"))); +@property (class, readonly) SMSStore6_coreOrigin *sot __attribute__((swift_name("sot"))); +@property (class, readonly) SMSStore6_coreOrigin *fetcher __attribute__((swift_name("fetcher"))); +@property (class, readonly) SMSStore6_coreOrigin *overlay __attribute__((swift_name("overlay"))); +@property (class, readonly) NSArray *entries __attribute__((swift_name("entries"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); +- (instancetype)initWithName:(NSString *)name ordinal:(int32_t)ordinal __attribute__((swift_name("init(name:ordinal:)"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable)); ++ (SMSKotlinArray *)values __attribute__((swift_name("values()"))); +@end + +#pragma pop_macro("_Nullable_result") +#pragma clang diagnostic pop +NS_ASSUME_NONNULL_END diff --git a/store6-mutations/api/swift/skie/Store6MutationsSkie.swift b/store6-mutations/api/swift/skie/Store6MutationsSkie.swift new file mode 100644 index 000000000..e5fc0102b --- /dev/null +++ b/store6-mutations/api/swift/skie/Store6MutationsSkie.swift @@ -0,0 +1,3864 @@ +// FILE: KotlinxCoroutinesCore/KotlinxCoroutinesCore.Flow.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6MutationsSkie.Kotlinx_coroutines_coreFlow { + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func collect(collector: Store6MutationsSkie.Kotlinx_coroutines_coreFlowCollector) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6MutationsSkie.__SkieSuspendWrappersKt.Skie_Suspend__9__collect(dispatchReceiver: self, collector: collector, suspendHandler: $0) + } + } + +} + +// FILE: KotlinxCoroutinesCore/KotlinxCoroutinesCore.FlowCollector.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6MutationsSkie.Kotlinx_coroutines_coreFlowCollector { + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func emit(value: Any?) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6MutationsSkie.__SkieSuspendWrappersKt.Skie_Suspend__10__emit(dispatchReceiver: self, value: value, suspendHandler: $0) + } + } + +} + +// FILE: Skie/Skie.AsyncStreamDispatcherDelegate.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +class AsyncStreamDispatcherDelegate: Skie.co_touchlab_skie__runtime_kotlin.Skie_DispatcherDelegate.__Kotlin { + + private let continuation: _Concurrency.AsyncStream.Continuation + + init(continuation: _Concurrency.AsyncStream.Continuation) { + self.continuation = continuation + } + + func dispatch(block: Skie.org_jetbrains_kotlinx__kotlinx_coroutines_core.Runnable.__Kotlin) { + let result = continuation.yield(block) + + if case .terminated = result { + Swift.fatalError("Cannot dispatch blocks after the dispatcher is stopped. This error might have happened by leaking the dispatcher from the original job.") + } + } + + func stop() { + continuation.finish() + } +} + +// FILE: Skie/Skie.FlowConversions.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public func SkieKotlinFlow(_ flow: Store6MutationsSkie.SkieSwiftFlow) -> Store6MutationsSkie.SkieKotlinFlow { + return Store6MutationsSkie.SkieKotlinFlow(flow.delegate) +} + +public func SkieKotlinFlow(_ flow: Store6MutationsSkie.SkieSwiftSharedFlow) -> Store6MutationsSkie.SkieKotlinFlow { + return Store6MutationsSkie.SkieKotlinFlow(flow.delegate) +} + +public func SkieKotlinFlow(_ flow: Store6MutationsSkie.SkieSwiftMutableSharedFlow) -> Store6MutationsSkie.SkieKotlinFlow { + return Store6MutationsSkie.SkieKotlinFlow(flow.delegate) +} + +public func SkieKotlinFlow(_ flow: Store6MutationsSkie.SkieSwiftStateFlow) -> Store6MutationsSkie.SkieKotlinFlow { + return Store6MutationsSkie.SkieKotlinFlow(flow.delegate) +} + +public func SkieKotlinFlow(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) -> Store6MutationsSkie.SkieKotlinFlow { + return Store6MutationsSkie.SkieKotlinFlow(flow.delegate) +} + +public func SkieKotlinFlow(_ flow: Store6MutationsSkie.SkieSwiftFlow) -> Store6MutationsSkie.SkieKotlinFlow { + return Store6MutationsSkie.SkieKotlinFlow(flow.delegate) +} + +public func SkieKotlinFlow(_ flow: Store6MutationsSkie.SkieSwiftSharedFlow) -> Store6MutationsSkie.SkieKotlinFlow { + return Store6MutationsSkie.SkieKotlinFlow(flow.delegate) +} + +public func SkieKotlinFlow(_ flow: Store6MutationsSkie.SkieSwiftMutableSharedFlow) -> Store6MutationsSkie.SkieKotlinFlow { + return Store6MutationsSkie.SkieKotlinFlow(flow.delegate) +} + +public func SkieKotlinFlow(_ flow: Store6MutationsSkie.SkieSwiftStateFlow) -> Store6MutationsSkie.SkieKotlinFlow { + return Store6MutationsSkie.SkieKotlinFlow(flow.delegate) +} + +public func SkieKotlinFlow(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) -> Store6MutationsSkie.SkieKotlinFlow { + return Store6MutationsSkie.SkieKotlinFlow(flow.delegate) +} + +extension Store6MutationsSkie.SkieSwiftFlow where T : Swift.AnyObject { + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +extension Store6MutationsSkie.SkieSwiftFlow where T : Swift._ObjectiveCBridgeable { + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +public func SkieKotlinOptionalFlow(_ flow: Store6MutationsSkie.SkieSwiftFlow) -> Store6MutationsSkie.SkieKotlinOptionalFlow { + return Store6MutationsSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6MutationsSkie.SkieSwiftOptionalFlow) -> Store6MutationsSkie.SkieKotlinOptionalFlow { + return Store6MutationsSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6MutationsSkie.SkieSwiftSharedFlow) -> Store6MutationsSkie.SkieKotlinOptionalFlow { + return Store6MutationsSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6MutationsSkie.SkieSwiftOptionalSharedFlow) -> Store6MutationsSkie.SkieKotlinOptionalFlow { + return Store6MutationsSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6MutationsSkie.SkieSwiftMutableSharedFlow) -> Store6MutationsSkie.SkieKotlinOptionalFlow { + return Store6MutationsSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6MutationsSkie.SkieSwiftOptionalMutableSharedFlow) -> Store6MutationsSkie.SkieKotlinOptionalFlow { + return Store6MutationsSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6MutationsSkie.SkieSwiftStateFlow) -> Store6MutationsSkie.SkieKotlinOptionalFlow { + return Store6MutationsSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6MutationsSkie.SkieSwiftOptionalStateFlow) -> Store6MutationsSkie.SkieKotlinOptionalFlow { + return Store6MutationsSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) -> Store6MutationsSkie.SkieKotlinOptionalFlow { + return Store6MutationsSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6MutationsSkie.SkieSwiftOptionalMutableStateFlow) -> Store6MutationsSkie.SkieKotlinOptionalFlow { + return Store6MutationsSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6MutationsSkie.SkieSwiftFlow) -> Store6MutationsSkie.SkieKotlinOptionalFlow { + return Store6MutationsSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6MutationsSkie.SkieSwiftOptionalFlow) -> Store6MutationsSkie.SkieKotlinOptionalFlow { + return Store6MutationsSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6MutationsSkie.SkieSwiftSharedFlow) -> Store6MutationsSkie.SkieKotlinOptionalFlow { + return Store6MutationsSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6MutationsSkie.SkieSwiftOptionalSharedFlow) -> Store6MutationsSkie.SkieKotlinOptionalFlow { + return Store6MutationsSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6MutationsSkie.SkieSwiftMutableSharedFlow) -> Store6MutationsSkie.SkieKotlinOptionalFlow { + return Store6MutationsSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6MutationsSkie.SkieSwiftOptionalMutableSharedFlow) -> Store6MutationsSkie.SkieKotlinOptionalFlow { + return Store6MutationsSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6MutationsSkie.SkieSwiftStateFlow) -> Store6MutationsSkie.SkieKotlinOptionalFlow { + return Store6MutationsSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6MutationsSkie.SkieSwiftOptionalStateFlow) -> Store6MutationsSkie.SkieKotlinOptionalFlow { + return Store6MutationsSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) -> Store6MutationsSkie.SkieKotlinOptionalFlow { + return Store6MutationsSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6MutationsSkie.SkieSwiftOptionalMutableStateFlow) -> Store6MutationsSkie.SkieKotlinOptionalFlow { + return Store6MutationsSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +extension Store6MutationsSkie.SkieSwiftOptionalFlow where T : Swift.AnyObject { + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinOptionalFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinOptionalSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinOptionalMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinOptionalStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinOptionalMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +extension Store6MutationsSkie.SkieSwiftOptionalFlow where T : Swift._ObjectiveCBridgeable { + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinOptionalFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinOptionalSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinOptionalMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinOptionalStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinOptionalMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +public func SkieKotlinSharedFlow(_ flow: Store6MutationsSkie.SkieSwiftSharedFlow) -> Store6MutationsSkie.SkieKotlinSharedFlow { + return Store6MutationsSkie.SkieKotlinSharedFlow(flow.delegate) +} + +public func SkieKotlinSharedFlow(_ flow: Store6MutationsSkie.SkieSwiftMutableSharedFlow) -> Store6MutationsSkie.SkieKotlinSharedFlow { + return Store6MutationsSkie.SkieKotlinSharedFlow(flow.delegate) +} + +public func SkieKotlinSharedFlow(_ flow: Store6MutationsSkie.SkieSwiftStateFlow) -> Store6MutationsSkie.SkieKotlinSharedFlow { + return Store6MutationsSkie.SkieKotlinSharedFlow(flow.delegate) +} + +public func SkieKotlinSharedFlow(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) -> Store6MutationsSkie.SkieKotlinSharedFlow { + return Store6MutationsSkie.SkieKotlinSharedFlow(flow.delegate) +} + +public func SkieKotlinSharedFlow(_ flow: Store6MutationsSkie.SkieSwiftSharedFlow) -> Store6MutationsSkie.SkieKotlinSharedFlow { + return Store6MutationsSkie.SkieKotlinSharedFlow(flow.delegate) +} + +public func SkieKotlinSharedFlow(_ flow: Store6MutationsSkie.SkieSwiftMutableSharedFlow) -> Store6MutationsSkie.SkieKotlinSharedFlow { + return Store6MutationsSkie.SkieKotlinSharedFlow(flow.delegate) +} + +public func SkieKotlinSharedFlow(_ flow: Store6MutationsSkie.SkieSwiftStateFlow) -> Store6MutationsSkie.SkieKotlinSharedFlow { + return Store6MutationsSkie.SkieKotlinSharedFlow(flow.delegate) +} + +public func SkieKotlinSharedFlow(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) -> Store6MutationsSkie.SkieKotlinSharedFlow { + return Store6MutationsSkie.SkieKotlinSharedFlow(flow.delegate) +} + +extension Store6MutationsSkie.SkieSwiftSharedFlow where T : Swift.AnyObject { + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +extension Store6MutationsSkie.SkieSwiftSharedFlow where T : Swift._ObjectiveCBridgeable { + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6MutationsSkie.SkieSwiftSharedFlow) -> Store6MutationsSkie.SkieKotlinOptionalSharedFlow { + return Store6MutationsSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6MutationsSkie.SkieSwiftOptionalSharedFlow) -> Store6MutationsSkie.SkieKotlinOptionalSharedFlow { + return Store6MutationsSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6MutationsSkie.SkieSwiftMutableSharedFlow) -> Store6MutationsSkie.SkieKotlinOptionalSharedFlow { + return Store6MutationsSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6MutationsSkie.SkieSwiftOptionalMutableSharedFlow) -> Store6MutationsSkie.SkieKotlinOptionalSharedFlow { + return Store6MutationsSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6MutationsSkie.SkieSwiftStateFlow) -> Store6MutationsSkie.SkieKotlinOptionalSharedFlow { + return Store6MutationsSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6MutationsSkie.SkieSwiftOptionalStateFlow) -> Store6MutationsSkie.SkieKotlinOptionalSharedFlow { + return Store6MutationsSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) -> Store6MutationsSkie.SkieKotlinOptionalSharedFlow { + return Store6MutationsSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6MutationsSkie.SkieSwiftOptionalMutableStateFlow) -> Store6MutationsSkie.SkieKotlinOptionalSharedFlow { + return Store6MutationsSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6MutationsSkie.SkieSwiftSharedFlow) -> Store6MutationsSkie.SkieKotlinOptionalSharedFlow { + return Store6MutationsSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6MutationsSkie.SkieSwiftOptionalSharedFlow) -> Store6MutationsSkie.SkieKotlinOptionalSharedFlow { + return Store6MutationsSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6MutationsSkie.SkieSwiftMutableSharedFlow) -> Store6MutationsSkie.SkieKotlinOptionalSharedFlow { + return Store6MutationsSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6MutationsSkie.SkieSwiftOptionalMutableSharedFlow) -> Store6MutationsSkie.SkieKotlinOptionalSharedFlow { + return Store6MutationsSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6MutationsSkie.SkieSwiftStateFlow) -> Store6MutationsSkie.SkieKotlinOptionalSharedFlow { + return Store6MutationsSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6MutationsSkie.SkieSwiftOptionalStateFlow) -> Store6MutationsSkie.SkieKotlinOptionalSharedFlow { + return Store6MutationsSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) -> Store6MutationsSkie.SkieKotlinOptionalSharedFlow { + return Store6MutationsSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6MutationsSkie.SkieSwiftOptionalMutableStateFlow) -> Store6MutationsSkie.SkieKotlinOptionalSharedFlow { + return Store6MutationsSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +extension Store6MutationsSkie.SkieSwiftOptionalSharedFlow where T : Swift.AnyObject { + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinOptionalSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinOptionalMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinOptionalStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinOptionalMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +extension Store6MutationsSkie.SkieSwiftOptionalSharedFlow where T : Swift._ObjectiveCBridgeable { + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinOptionalSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinOptionalMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinOptionalStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinOptionalMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +public func SkieKotlinMutableSharedFlow(_ flow: Store6MutationsSkie.SkieSwiftMutableSharedFlow) -> Store6MutationsSkie.SkieKotlinMutableSharedFlow { + return Store6MutationsSkie.SkieKotlinMutableSharedFlow(flow.delegate) +} + +public func SkieKotlinMutableSharedFlow(_ flow: Store6MutationsSkie.SkieSwiftMutableSharedFlow) -> Store6MutationsSkie.SkieKotlinMutableSharedFlow { + return Store6MutationsSkie.SkieKotlinMutableSharedFlow(flow.delegate) +} + +extension Store6MutationsSkie.SkieSwiftMutableSharedFlow where T : Swift.AnyObject { + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + +} + +extension Store6MutationsSkie.SkieSwiftMutableSharedFlow where T : Swift._ObjectiveCBridgeable { + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + +} + +public func SkieKotlinOptionalMutableSharedFlow(_ flow: Store6MutationsSkie.SkieSwiftMutableSharedFlow) -> Store6MutationsSkie.SkieKotlinOptionalMutableSharedFlow { + return Store6MutationsSkie.SkieKotlinOptionalMutableSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalMutableSharedFlow(_ flow: Store6MutationsSkie.SkieSwiftOptionalMutableSharedFlow) -> Store6MutationsSkie.SkieKotlinOptionalMutableSharedFlow { + return Store6MutationsSkie.SkieKotlinOptionalMutableSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalMutableSharedFlow(_ flow: Store6MutationsSkie.SkieSwiftMutableSharedFlow) -> Store6MutationsSkie.SkieKotlinOptionalMutableSharedFlow { + return Store6MutationsSkie.SkieKotlinOptionalMutableSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalMutableSharedFlow(_ flow: Store6MutationsSkie.SkieSwiftOptionalMutableSharedFlow) -> Store6MutationsSkie.SkieKotlinOptionalMutableSharedFlow { + return Store6MutationsSkie.SkieKotlinOptionalMutableSharedFlow(flow.delegate) +} + +extension Store6MutationsSkie.SkieSwiftOptionalMutableSharedFlow where T : Swift.AnyObject { + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinOptionalMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalMutableSharedFlow) { + self.init(internal: flow.delegate) + } + +} + +extension Store6MutationsSkie.SkieSwiftOptionalMutableSharedFlow + where T : Swift._ObjectiveCBridgeable { + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinOptionalMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalMutableSharedFlow) { + self.init(internal: flow.delegate) + } + +} + +public func SkieKotlinStateFlow(_ flow: Store6MutationsSkie.SkieSwiftStateFlow) -> Store6MutationsSkie.SkieKotlinStateFlow { + return Store6MutationsSkie.SkieKotlinStateFlow(flow.delegate) +} + +public func SkieKotlinStateFlow(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) -> Store6MutationsSkie.SkieKotlinStateFlow { + return Store6MutationsSkie.SkieKotlinStateFlow(flow.delegate) +} + +public func SkieKotlinStateFlow(_ flow: Store6MutationsSkie.SkieSwiftStateFlow) -> Store6MutationsSkie.SkieKotlinStateFlow { + return Store6MutationsSkie.SkieKotlinStateFlow(flow.delegate) +} + +public func SkieKotlinStateFlow(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) -> Store6MutationsSkie.SkieKotlinStateFlow { + return Store6MutationsSkie.SkieKotlinStateFlow(flow.delegate) +} + +extension Store6MutationsSkie.SkieSwiftStateFlow where T : Swift.AnyObject { + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +extension Store6MutationsSkie.SkieSwiftStateFlow where T : Swift._ObjectiveCBridgeable { + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +public func SkieKotlinOptionalStateFlow(_ flow: Store6MutationsSkie.SkieSwiftStateFlow) -> Store6MutationsSkie.SkieKotlinOptionalStateFlow { + return Store6MutationsSkie.SkieKotlinOptionalStateFlow(flow.delegate) +} + +public func SkieKotlinOptionalStateFlow(_ flow: Store6MutationsSkie.SkieSwiftOptionalStateFlow) -> Store6MutationsSkie.SkieKotlinOptionalStateFlow { + return Store6MutationsSkie.SkieKotlinOptionalStateFlow(flow.delegate) +} + +public func SkieKotlinOptionalStateFlow(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) -> Store6MutationsSkie.SkieKotlinOptionalStateFlow { + return Store6MutationsSkie.SkieKotlinOptionalStateFlow(flow.delegate) +} + +public func SkieKotlinOptionalStateFlow(_ flow: Store6MutationsSkie.SkieSwiftOptionalMutableStateFlow) -> Store6MutationsSkie.SkieKotlinOptionalStateFlow { + return Store6MutationsSkie.SkieKotlinOptionalStateFlow(flow.delegate) +} + +public func SkieKotlinOptionalStateFlow(_ flow: Store6MutationsSkie.SkieSwiftStateFlow) -> Store6MutationsSkie.SkieKotlinOptionalStateFlow { + return Store6MutationsSkie.SkieKotlinOptionalStateFlow(flow.delegate) +} + +public func SkieKotlinOptionalStateFlow(_ flow: Store6MutationsSkie.SkieSwiftOptionalStateFlow) -> Store6MutationsSkie.SkieKotlinOptionalStateFlow { + return Store6MutationsSkie.SkieKotlinOptionalStateFlow(flow.delegate) +} + +public func SkieKotlinOptionalStateFlow(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) -> Store6MutationsSkie.SkieKotlinOptionalStateFlow { + return Store6MutationsSkie.SkieKotlinOptionalStateFlow(flow.delegate) +} + +public func SkieKotlinOptionalStateFlow(_ flow: Store6MutationsSkie.SkieSwiftOptionalMutableStateFlow) -> Store6MutationsSkie.SkieKotlinOptionalStateFlow { + return Store6MutationsSkie.SkieKotlinOptionalStateFlow(flow.delegate) +} + +extension Store6MutationsSkie.SkieSwiftOptionalStateFlow where T : Swift.AnyObject { + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinOptionalStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinOptionalMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +extension Store6MutationsSkie.SkieSwiftOptionalStateFlow where T : Swift._ObjectiveCBridgeable { + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinOptionalStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinOptionalMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +public func SkieKotlinMutableStateFlow(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) -> Store6MutationsSkie.SkieKotlinMutableStateFlow { + return Store6MutationsSkie.SkieKotlinMutableStateFlow(flow.delegate) +} + +public func SkieKotlinMutableStateFlow(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) -> Store6MutationsSkie.SkieKotlinMutableStateFlow { + return Store6MutationsSkie.SkieKotlinMutableStateFlow(flow.delegate) +} + +extension Store6MutationsSkie.SkieSwiftMutableStateFlow where T : Swift.AnyObject { + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +extension Store6MutationsSkie.SkieSwiftMutableStateFlow where T : Swift._ObjectiveCBridgeable { + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +public func SkieKotlinOptionalMutableStateFlow(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) -> Store6MutationsSkie.SkieKotlinOptionalMutableStateFlow { + return Store6MutationsSkie.SkieKotlinOptionalMutableStateFlow(flow.delegate) +} + +public func SkieKotlinOptionalMutableStateFlow(_ flow: Store6MutationsSkie.SkieSwiftOptionalMutableStateFlow) -> Store6MutationsSkie.SkieKotlinOptionalMutableStateFlow { + return Store6MutationsSkie.SkieKotlinOptionalMutableStateFlow(flow.delegate) +} + +public func SkieKotlinOptionalMutableStateFlow(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) -> Store6MutationsSkie.SkieKotlinOptionalMutableStateFlow { + return Store6MutationsSkie.SkieKotlinOptionalMutableStateFlow(flow.delegate) +} + +public func SkieKotlinOptionalMutableStateFlow(_ flow: Store6MutationsSkie.SkieSwiftOptionalMutableStateFlow) -> Store6MutationsSkie.SkieKotlinOptionalMutableStateFlow { + return Store6MutationsSkie.SkieKotlinOptionalMutableStateFlow(flow.delegate) +} + +extension Store6MutationsSkie.SkieSwiftOptionalMutableStateFlow where T : Swift.AnyObject { + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinOptionalMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +extension Store6MutationsSkie.SkieSwiftOptionalMutableStateFlow + where T : Swift._ObjectiveCBridgeable { + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieKotlinOptionalMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6MutationsSkie.SkieSwiftOptionalMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +// FILE: Skie/Skie.Namespace.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public enum Skie { + + public enum KotlinxCoroutinesCore { + + public enum Flow { + + public typealias __Kotlin = Store6MutationsSkie.Kotlinx_coroutines_coreFlow + + } + + public enum SharedFlow { + + public typealias __Kotlin = Store6MutationsSkie.Kotlinx_coroutines_coreSharedFlow + + } + + public enum StateFlow { + + public typealias __Kotlin = Store6MutationsSkie.Kotlinx_coroutines_coreStateFlow + + } + + public enum MutableSharedFlow { + + public typealias __Kotlin = Store6MutationsSkie.Kotlinx_coroutines_coreMutableSharedFlow + + } + + public enum MutableStateFlow { + + public typealias __Kotlin = Store6MutationsSkie.Kotlinx_coroutines_coreMutableStateFlow + + } + + public enum Runnable { + + public typealias __Kotlin = Store6MutationsSkie.Kotlinx_coroutines_coreRunnable + + } + + } + + public typealias org_jetbrains_kotlinx__kotlinx_coroutines_core = Store6MutationsSkie.Skie.KotlinxCoroutinesCore + + public enum RuntimeKotlin { + + public enum SkieColdFlowIterator { + + public typealias __Kotlin = Store6MutationsSkie.SkieColdFlowIterator + + } + + public enum SkieKotlinFlow { + + public typealias __Kotlin = Store6MutationsSkie.SkieKotlinFlow + + } + + public enum SkieKotlinMutableSharedFlow { + + public typealias __Kotlin = Store6MutationsSkie.SkieKotlinMutableSharedFlow + + } + + public enum SkieKotlinMutableStateFlow { + + public typealias __Kotlin = Store6MutationsSkie.SkieKotlinMutableStateFlow + + } + + public enum SkieKotlinOptionalFlow { + + public typealias __Kotlin = Store6MutationsSkie.SkieKotlinOptionalFlow + + } + + public enum SkieKotlinOptionalMutableSharedFlow { + + public typealias __Kotlin = Store6MutationsSkie.SkieKotlinOptionalMutableSharedFlow + + } + + public enum SkieKotlinOptionalMutableStateFlow { + + public typealias __Kotlin = Store6MutationsSkie.SkieKotlinOptionalMutableStateFlow + + } + + public enum SkieKotlinOptionalSharedFlow { + + public typealias __Kotlin = Store6MutationsSkie.SkieKotlinOptionalSharedFlow + + } + + public enum SkieKotlinOptionalStateFlow { + + public typealias __Kotlin = Store6MutationsSkie.SkieKotlinOptionalStateFlow + + } + + public enum SkieKotlinSharedFlow { + + public typealias __Kotlin = Store6MutationsSkie.SkieKotlinSharedFlow + + } + + public enum SkieKotlinStateFlow { + + public typealias __Kotlin = Store6MutationsSkie.SkieKotlinStateFlow + + } + + public enum Skie_CancellationHandler { + + public typealias __Kotlin = Store6MutationsSkie.Skie_CancellationHandler + + } + + public enum Skie_DispatcherDelegate { + + public typealias __Kotlin = Store6MutationsSkie.Skie_DispatcherDelegate + + } + + public enum Skie_SuspendHandler { + + public typealias __Kotlin = Store6MutationsSkie.Skie_SuspendHandler + + } + + public enum Skie_SuspendResult { + + public typealias __Kotlin = Store6MutationsSkie.Skie_SuspendResult + + public enum Success { + + public typealias __Kotlin = Store6MutationsSkie.Skie_SuspendResult.Success + + } + + public enum Error { + + public typealias __Kotlin = Store6MutationsSkie.Skie_SuspendResult.Error + + } + + public enum Canceled { + + public typealias __Kotlin = Store6MutationsSkie.Skie_SuspendResult.Canceled + + } + + } + + } + + public typealias co_touchlab_skie__runtime_kotlin = Store6MutationsSkie.Skie.RuntimeKotlin + + public enum Store6Mutations { + + public enum MutationStore { + + } + + public enum MutationEvent { + + } + + public enum MutationIntentEvent { + + } + + public enum MutationPresence { + + } + + public enum MutationAck { + + } + + public enum MutationConflictResolution { + + } + + } + + public typealias Store5__store6_mutations = Store6MutationsSkie.Skie.Store6Mutations + + public enum Store6Core { + + public enum Freshness { + + } + + public enum StoreError { + + } + + public enum FetchPlan { + + } + + public enum FetcherResult { + + } + + public enum StoreResult { + + } + + } + + public typealias Store5__store6_core = Store6MutationsSkie.Skie.Store6Core + +} + +// FILE: Skie/Skie.SkieColdFlowIterator.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6MutationsSkie.Skie.RuntimeKotlin.SkieColdFlowIterator { + + public struct __Suspend { + + public let __kotlinObject: Store6MutationsSkie.SkieColdFlowIterator + + public init(_ __kotlinObject: Store6MutationsSkie.SkieColdFlowIterator) { + self.__kotlinObject = __kotlinObject + } + + } + +} + +public func skie(_ kotlinObject: Store6MutationsSkie.SkieColdFlowIterator) -> Store6MutationsSkie.Skie.RuntimeKotlin.SkieColdFlowIterator.__Suspend { + return Store6MutationsSkie.Skie.RuntimeKotlin.SkieColdFlowIterator.__Suspend(kotlinObject) +} + +extension Store6MutationsSkie.Skie.RuntimeKotlin.SkieColdFlowIterator.__Suspend { + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func hasNext() async throws -> Store6MutationsSkie.KotlinBoolean { + return try await SwiftCoroutineDispatcher.dispatch { + Store6MutationsSkie.__SkieSuspendWrappersKt.Skie_Suspend__34__hasNext(dispatchReceiver: __kotlinObject as! Store6MutationsSkie.SkieColdFlowIterator, suspendHandler: $0) + } + } + +} + +// FILE: Skie/Skie.SkieSwiftFlow.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public final class SkieSwiftFlow : Store6MutationsSkie.SkieSwiftFlowProtocol, + Swift._ObjectiveCBridgeable { + + @_spi(SKIE) + public let delegate: Store6MutationsSkie.Kotlinx_coroutines_coreFlow + + init(`internal` flow: Store6MutationsSkie.Kotlinx_coroutines_coreFlow) { + delegate = flow + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinFlow, result: inout Store6MutationsSkie.SkieSwiftFlow?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinFlow, result: inout Store6MutationsSkie.SkieSwiftFlow?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinFlow?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6MutationsSkie.SkieKotlinFlow { + return Store6MutationsSkie.SkieKotlinFlow(delegate) + } + + private static func fromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinFlow?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6MutationsSkie.SkieSwiftFlow") + } + return .init(internal: source) + } + + public func makeAsyncIterator() -> Store6MutationsSkie.SkieSwiftFlowIterator { + return SkieSwiftFlowIterator(flow: delegate) + } + + public typealias AsyncIterator = Store6MutationsSkie.SkieSwiftFlowIterator + + public typealias Element = T + + public typealias _ObjectiveCType = Store6MutationsSkie.SkieKotlinFlow + +} + +// FILE: Skie/Skie.SkieSwiftFlowIterator.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation +import _Concurrency + +public class SkieSwiftFlowIterator : _Concurrency.AsyncIteratorProtocol { + + private let iterator: Store6MutationsSkie.SkieColdFlowIterator + + init(flow: Store6MutationsSkie.Kotlinx_coroutines_coreFlow) { + iterator = .init(flow: flow) + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func next() async -> T? { + do { + let hasNext = try await skie(iterator).hasNext() + + if hasNext.boolValue { + return .some(iterator.next() as! Element) + } else { + return nil + } + } catch is _Concurrency.CancellationError { + await cancelTask() + + return nil + } catch { + Swift.fatalError("Unexpected error: \(error)") + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + private func cancelTask() async -> Swift.Void { + _Concurrency.withUnsafeCurrentTask { task in + task?.cancel() + } + } + + deinit { + iterator.cancel() + } + + public typealias Element = T + +} + +// FILE: Skie/Skie.SkieSwiftFlowProtocol.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation +import _Concurrency + +public protocol SkieSwiftFlowProtocol : _Concurrency.AsyncSequence { + + associatedtype Element + associatedtype Delegate : Store6MutationsSkie.Kotlinx_coroutines_coreFlow + + @_spi(SKIE) + var delegate: Delegate { get } + +} + +extension Store6MutationsSkie.SkieSwiftFlowProtocol { + + var delegate: Delegate { + Swift.fatalError("SkieSwiftFlowProtocol has to be conformed to with @_spi(SKIE) enabled and property 'delegate' implemented") + } + +} + +// FILE: Skie/Skie.SkieSwiftMutableSharedFlow.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public final class SkieSwiftMutableSharedFlow : Store6MutationsSkie.SkieSwiftFlowProtocol, + Swift._ObjectiveCBridgeable { + + @_spi(SKIE) + public let delegate: Store6MutationsSkie.Kotlinx_coroutines_coreMutableSharedFlow + + public var subscriptionCount: Store6MutationsSkie.SkieSwiftStateFlow { + bridgeSubscriptionCount(delegate.subscriptionCount) + } + + public var replayCache: [T] { + delegate.replayCache as! [T] + } + + init(`internal` flow: Store6MutationsSkie.Kotlinx_coroutines_coreMutableSharedFlow) { + delegate = flow + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func emit(value: T) async throws -> Swift.Void { + try await delegate.emit(value: value) + } + + public func tryEmit(value: T) -> Swift.Bool { + delegate.tryEmit(value: value) + } + + public func resetReplayCache() -> Swift.Void { + delegate.resetReplayCache() + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinMutableSharedFlow, result: inout Store6MutationsSkie.SkieSwiftMutableSharedFlow?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinMutableSharedFlow, result: inout Store6MutationsSkie.SkieSwiftMutableSharedFlow?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinMutableSharedFlow?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6MutationsSkie.SkieKotlinMutableSharedFlow { + return Store6MutationsSkie.SkieKotlinMutableSharedFlow(delegate) + } + + private static func fromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinMutableSharedFlow?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6MutationsSkie.SkieSwiftMutableSharedFlow") + } + return .init(internal: source) + } + + public func makeAsyncIterator() -> Store6MutationsSkie.SkieSwiftFlowIterator { + return SkieSwiftFlowIterator(flow: delegate) + } + + public typealias AsyncIterator = Store6MutationsSkie.SkieSwiftFlowIterator + + public typealias Element = T + + public typealias _ObjectiveCType = Store6MutationsSkie.SkieKotlinMutableSharedFlow + +} + +// FILE: Skie/Skie.SkieSwiftMutableStateFlow.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public final class SkieSwiftMutableStateFlow : Store6MutationsSkie.SkieSwiftFlowProtocol, + Swift._ObjectiveCBridgeable { + + @_spi(SKIE) + public let delegate: Store6MutationsSkie.Kotlinx_coroutines_coreMutableStateFlow + + public var value: T { + get { + delegate.value as! T + } + set(value) { + delegate.setValue(value) + } + } + + public var replayCache: [T] { + delegate.replayCache as! [T] + } + + public var subscriptionCount: Store6MutationsSkie.SkieSwiftStateFlow { + bridgeSubscriptionCount(delegate.subscriptionCount) + } + + init(`internal` flow: Store6MutationsSkie.Kotlinx_coroutines_coreMutableStateFlow) { + delegate = flow + } + + public func compareAndSet(expect: T, update: T) -> Swift.Bool { + delegate.compareAndSet(expect: expect, update: update) + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func emit(value: T) async throws -> Swift.Void { + try await delegate.emit(value: value) + } + + public func tryEmit(value: T) -> Swift.Bool { + delegate.tryEmit(value: value) + } + + public func resetReplayCache() -> Swift.Void { + delegate.resetReplayCache() + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinMutableStateFlow, result: inout Store6MutationsSkie.SkieSwiftMutableStateFlow?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinMutableStateFlow, result: inout Store6MutationsSkie.SkieSwiftMutableStateFlow?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinMutableStateFlow?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6MutationsSkie.SkieKotlinMutableStateFlow { + return Store6MutationsSkie.SkieKotlinMutableStateFlow(delegate) + } + + private static func fromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinMutableStateFlow?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6MutationsSkie.SkieSwiftMutableStateFlow") + } + return .init(internal: source) + } + + public func makeAsyncIterator() -> Store6MutationsSkie.SkieSwiftFlowIterator { + return SkieSwiftFlowIterator(flow: delegate) + } + + public typealias AsyncIterator = Store6MutationsSkie.SkieSwiftFlowIterator + + public typealias Element = T + + public typealias _ObjectiveCType = Store6MutationsSkie.SkieKotlinMutableStateFlow + +} + +// FILE: Skie/Skie.SkieSwiftOptionalFlow.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public final class SkieSwiftOptionalFlow : Store6MutationsSkie.SkieSwiftFlowProtocol, + Swift._ObjectiveCBridgeable { + + @_spi(SKIE) + public let delegate: Store6MutationsSkie.Kotlinx_coroutines_coreFlow + + init(`internal` flow: Store6MutationsSkie.Kotlinx_coroutines_coreFlow) { + delegate = flow + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinOptionalFlow, result: inout Store6MutationsSkie.SkieSwiftOptionalFlow?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinOptionalFlow, result: inout Store6MutationsSkie.SkieSwiftOptionalFlow?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinOptionalFlow?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6MutationsSkie.SkieKotlinOptionalFlow { + return Store6MutationsSkie.SkieKotlinOptionalFlow(delegate) + } + + private static func fromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinOptionalFlow?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6MutationsSkie.SkieSwiftOptionalFlow") + } + return .init(internal: source) + } + + public func makeAsyncIterator() -> Store6MutationsSkie.SkieSwiftFlowIterator { + return SkieSwiftFlowIterator(flow: delegate) + } + + public typealias AsyncIterator = Store6MutationsSkie.SkieSwiftFlowIterator + + public typealias Element = T? + + public typealias _ObjectiveCType = Store6MutationsSkie.SkieKotlinOptionalFlow + +} + +// FILE: Skie/Skie.SkieSwiftOptionalMutableSharedFlow.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public final class SkieSwiftOptionalMutableSharedFlow : Store6MutationsSkie.SkieSwiftFlowProtocol, + Swift._ObjectiveCBridgeable { + + @_spi(SKIE) + public let delegate: Store6MutationsSkie.Kotlinx_coroutines_coreMutableSharedFlow + + public var subscriptionCount: Store6MutationsSkie.SkieSwiftStateFlow { + bridgeSubscriptionCount(delegate.subscriptionCount) + } + + public var replayCache: [T?] { + delegate.replayCache as! [T?] + } + + init(`internal` flow: Store6MutationsSkie.Kotlinx_coroutines_coreMutableSharedFlow) { + delegate = flow + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func emit(value: T?) async throws -> Swift.Void { + try await delegate.emit(value: value) + } + + public func tryEmit(value: T?) -> Swift.Bool { + delegate.tryEmit(value: value) + } + + public func resetReplayCache() -> Swift.Void { + delegate.resetReplayCache() + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinOptionalMutableSharedFlow, result: inout Store6MutationsSkie.SkieSwiftOptionalMutableSharedFlow?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinOptionalMutableSharedFlow, result: inout Store6MutationsSkie.SkieSwiftOptionalMutableSharedFlow?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinOptionalMutableSharedFlow?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6MutationsSkie.SkieKotlinOptionalMutableSharedFlow { + return Store6MutationsSkie.SkieKotlinOptionalMutableSharedFlow(delegate) + } + + private static func fromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinOptionalMutableSharedFlow?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6MutationsSkie.SkieSwiftOptionalMutableSharedFlow") + } + return .init(internal: source) + } + + public func makeAsyncIterator() -> Store6MutationsSkie.SkieSwiftFlowIterator { + return SkieSwiftFlowIterator(flow: delegate) + } + + public typealias AsyncIterator = Store6MutationsSkie.SkieSwiftFlowIterator + + public typealias Element = T? + + public typealias _ObjectiveCType = Store6MutationsSkie.SkieKotlinOptionalMutableSharedFlow + +} + +// FILE: Skie/Skie.SkieSwiftOptionalMutableStateFlow.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public final class SkieSwiftOptionalMutableStateFlow : Store6MutationsSkie.SkieSwiftFlowProtocol, + Swift._ObjectiveCBridgeable { + + @_spi(SKIE) + public let delegate: Store6MutationsSkie.Kotlinx_coroutines_coreMutableStateFlow + + public var value: T? { + get { + delegate.value as! T? + } + set(value) { + delegate.setValue(value) + } + } + + public var replayCache: [T?] { + delegate.replayCache as! [T?] + } + + public var subscriptionCount: Store6MutationsSkie.SkieSwiftStateFlow { + bridgeSubscriptionCount(delegate.subscriptionCount) + } + + init(`internal` flow: Store6MutationsSkie.Kotlinx_coroutines_coreMutableStateFlow) { + delegate = flow + } + + public func compareAndSet(expect: T?, update: T?) -> Swift.Bool { + delegate.compareAndSet(expect: expect, update: update) + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func emit(value: T?) async throws -> Swift.Void { + try await delegate.emit(value: value) + } + + public func tryEmit(value: T?) -> Swift.Bool { + delegate.tryEmit(value: value) + } + + public func resetReplayCache() -> Swift.Void { + delegate.resetReplayCache() + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinOptionalMutableStateFlow, result: inout Store6MutationsSkie.SkieSwiftOptionalMutableStateFlow?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinOptionalMutableStateFlow, result: inout Store6MutationsSkie.SkieSwiftOptionalMutableStateFlow?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinOptionalMutableStateFlow?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6MutationsSkie.SkieKotlinOptionalMutableStateFlow { + return Store6MutationsSkie.SkieKotlinOptionalMutableStateFlow(delegate) + } + + private static func fromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinOptionalMutableStateFlow?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6MutationsSkie.SkieSwiftOptionalMutableStateFlow") + } + return .init(internal: source) + } + + public func makeAsyncIterator() -> Store6MutationsSkie.SkieSwiftFlowIterator { + return SkieSwiftFlowIterator(flow: delegate) + } + + public typealias AsyncIterator = Store6MutationsSkie.SkieSwiftFlowIterator + + public typealias Element = T? + + public typealias _ObjectiveCType = Store6MutationsSkie.SkieKotlinOptionalMutableStateFlow + +} + +// FILE: Skie/Skie.SkieSwiftOptionalSharedFlow.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public final class SkieSwiftOptionalSharedFlow : Store6MutationsSkie.SkieSwiftFlowProtocol, + Swift._ObjectiveCBridgeable { + + @_spi(SKIE) + public let delegate: Store6MutationsSkie.Kotlinx_coroutines_coreSharedFlow + + public var replayCache: [T?] { + delegate.replayCache as! [T?] + } + + init(`internal` flow: Store6MutationsSkie.Kotlinx_coroutines_coreSharedFlow) { + delegate = flow + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinOptionalSharedFlow, result: inout Store6MutationsSkie.SkieSwiftOptionalSharedFlow?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinOptionalSharedFlow, result: inout Store6MutationsSkie.SkieSwiftOptionalSharedFlow?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinOptionalSharedFlow?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6MutationsSkie.SkieKotlinOptionalSharedFlow { + return Store6MutationsSkie.SkieKotlinOptionalSharedFlow(delegate) + } + + private static func fromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinOptionalSharedFlow?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6MutationsSkie.SkieSwiftOptionalSharedFlow") + } + return .init(internal: source) + } + + public func makeAsyncIterator() -> Store6MutationsSkie.SkieSwiftFlowIterator { + return SkieSwiftFlowIterator(flow: delegate) + } + + public typealias AsyncIterator = Store6MutationsSkie.SkieSwiftFlowIterator + + public typealias Element = T? + + public typealias _ObjectiveCType = Store6MutationsSkie.SkieKotlinOptionalSharedFlow + +} + +// FILE: Skie/Skie.SkieSwiftOptionalStateFlow.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public final class SkieSwiftOptionalStateFlow : Store6MutationsSkie.SkieSwiftFlowProtocol, + Swift._ObjectiveCBridgeable { + + @_spi(SKIE) + public let delegate: Store6MutationsSkie.Kotlinx_coroutines_coreStateFlow + + public var value: T? { + delegate.value as! T? + } + + public var replayCache: [T?] { + delegate.replayCache as! [T?] + } + + init(`internal` flow: Store6MutationsSkie.Kotlinx_coroutines_coreStateFlow) { + delegate = flow + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinOptionalStateFlow, result: inout Store6MutationsSkie.SkieSwiftOptionalStateFlow?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinOptionalStateFlow, result: inout Store6MutationsSkie.SkieSwiftOptionalStateFlow?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinOptionalStateFlow?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6MutationsSkie.SkieKotlinOptionalStateFlow { + return Store6MutationsSkie.SkieKotlinOptionalStateFlow(delegate) + } + + private static func fromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinOptionalStateFlow?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6MutationsSkie.SkieSwiftOptionalStateFlow") + } + return .init(internal: source) + } + + public func makeAsyncIterator() -> Store6MutationsSkie.SkieSwiftFlowIterator { + return SkieSwiftFlowIterator(flow: delegate) + } + + public typealias AsyncIterator = Store6MutationsSkie.SkieSwiftFlowIterator + + public typealias Element = T? + + public typealias _ObjectiveCType = Store6MutationsSkie.SkieKotlinOptionalStateFlow + +} + +// FILE: Skie/Skie.SkieSwiftSharedFlow.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public final class SkieSwiftSharedFlow : Store6MutationsSkie.SkieSwiftFlowProtocol, + Swift._ObjectiveCBridgeable { + + @_spi(SKIE) + public let delegate: Store6MutationsSkie.Kotlinx_coroutines_coreSharedFlow + + public var replayCache: [T] { + delegate.replayCache as! [T] + } + + init(`internal` flow: Store6MutationsSkie.Kotlinx_coroutines_coreSharedFlow) { + delegate = flow + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinSharedFlow, result: inout Store6MutationsSkie.SkieSwiftSharedFlow?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinSharedFlow, result: inout Store6MutationsSkie.SkieSwiftSharedFlow?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinSharedFlow?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6MutationsSkie.SkieKotlinSharedFlow { + return Store6MutationsSkie.SkieKotlinSharedFlow(delegate) + } + + private static func fromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinSharedFlow?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6MutationsSkie.SkieSwiftSharedFlow") + } + return .init(internal: source) + } + + public func makeAsyncIterator() -> Store6MutationsSkie.SkieSwiftFlowIterator { + return SkieSwiftFlowIterator(flow: delegate) + } + + public typealias AsyncIterator = Store6MutationsSkie.SkieSwiftFlowIterator + + public typealias Element = T + + public typealias _ObjectiveCType = Store6MutationsSkie.SkieKotlinSharedFlow + +} + +// FILE: Skie/Skie.SkieSwiftStateFlow.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public final class SkieSwiftStateFlow : Store6MutationsSkie.SkieSwiftFlowProtocol, + Swift._ObjectiveCBridgeable { + + @_spi(SKIE) + public let delegate: Store6MutationsSkie.Kotlinx_coroutines_coreStateFlow + + public var value: T { + delegate.value as! T + } + + public var replayCache: [T] { + delegate.replayCache as! [T] + } + + init(`internal` flow: Store6MutationsSkie.Kotlinx_coroutines_coreStateFlow) { + delegate = flow + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinStateFlow, result: inout Store6MutationsSkie.SkieSwiftStateFlow?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinStateFlow, result: inout Store6MutationsSkie.SkieSwiftStateFlow?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinStateFlow?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6MutationsSkie.SkieKotlinStateFlow { + return Store6MutationsSkie.SkieKotlinStateFlow(delegate) + } + + private static func fromObjectiveC(_ source: Store6MutationsSkie.SkieKotlinStateFlow?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6MutationsSkie.SkieSwiftStateFlow") + } + return .init(internal: source) + } + + public func makeAsyncIterator() -> Store6MutationsSkie.SkieSwiftFlowIterator { + return SkieSwiftFlowIterator(flow: delegate) + } + + public typealias AsyncIterator = Store6MutationsSkie.SkieSwiftFlowIterator + + public typealias Element = T + + public typealias _ObjectiveCType = Store6MutationsSkie.SkieKotlinStateFlow + +} + +// FILE: Skie/Skie.Skie_SuspendResult.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6MutationsSkie.Skie.RuntimeKotlin.Skie_SuspendResult { + + @frozen + public enum __Sealed : Swift.Hashable { + + case canceled(Store6MutationsSkie.Skie_SuspendResult.Canceled) + case error(Store6MutationsSkie.Skie_SuspendResult.Error) + case success(Store6MutationsSkie.Skie_SuspendResult.Success) + + } + +} + +public func onEnum<__Sealed : Store6MutationsSkie.Skie_SuspendResult>(of sealed: __Sealed) -> Store6MutationsSkie.Skie.RuntimeKotlin.Skie_SuspendResult.__Sealed { + if let sealed = sealed as? Store6MutationsSkie.Skie_SuspendResult.Canceled { + return Store6MutationsSkie.Skie.RuntimeKotlin.Skie_SuspendResult.__Sealed.canceled(sealed) + } else if let sealed = sealed as? Store6MutationsSkie.Skie_SuspendResult.Error { + return Store6MutationsSkie.Skie.RuntimeKotlin.Skie_SuspendResult.__Sealed.error(sealed) + } else if let sealed = sealed as? Store6MutationsSkie.Skie_SuspendResult.Success { + return Store6MutationsSkie.Skie.RuntimeKotlin.Skie_SuspendResult.__Sealed.success(sealed) + } else { + fatalError("Unknown subtype \(sealed). This error should not happen under normal circumstances since SirClass: Store6MutationsSkie.Skie_SuspendResult is sealed.") + } +} + +@_disfavoredOverload +public func onEnum<__Sealed : Store6MutationsSkie.Skie_SuspendResult>(of sealed: __Sealed?) -> Store6MutationsSkie.Skie.RuntimeKotlin.Skie_SuspendResult.__Sealed? { + if let sealed { + return onEnum(of: sealed) as Store6MutationsSkie.Skie.RuntimeKotlin.Skie_SuspendResult.__Sealed + } else { + return nil + } +} + +// FILE: Skie/Skie.SwiftCoroutineDispatcher.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +struct SwiftCoroutineDispatcher { + + static func dispatch( + coroutine: (Skie.co_touchlab_skie__runtime_kotlin.Skie_SuspendHandler.__Kotlin) -> Swift.Void + ) async throws -> T { + let cancellationHandler = Skie.co_touchlab_skie__runtime_kotlin.Skie_CancellationHandler.__Kotlin() + + return try await _Concurrency.withTaskCancellationHandler(operation: { + try await dispatchCancellable(coroutine: coroutine, cancellationHandler: cancellationHandler) + }, onCancel: { + cancellationHandler.cancel() + }) + } + + private static func dispatchCancellable( + coroutine: (Skie.co_touchlab_skie__runtime_kotlin.Skie_SuspendHandler.__Kotlin) -> Swift.Void, + cancellationHandler: Skie.co_touchlab_skie__runtime_kotlin.Skie_CancellationHandler.__Kotlin + ) async throws -> T { + var result: Swift.Result? = nil + + let dispatcher = createDispatcher(coroutine: coroutine, cancellationHandler: cancellationHandler) { + result = $0 + } + + await executeWithoutCancellation(dispatcher: dispatcher) + + return try unwrap(result: result) + } + + private static func createDispatcher( + coroutine: (Skie.co_touchlab_skie__runtime_kotlin.Skie_SuspendHandler.__Kotlin) -> Swift.Void, + cancellationHandler: Skie.co_touchlab_skie__runtime_kotlin.Skie_CancellationHandler.__Kotlin, + onResult: @escaping (Swift.Result) -> Swift.Void + ) -> _Concurrency.AsyncStream { + return _Concurrency.AsyncStream { continuation in + let dispatcherDelegate = AsyncStreamDispatcherDelegate(continuation: continuation) + + let suspendHandler = Skie.co_touchlab_skie__runtime_kotlin.Skie_SuspendHandler.__Kotlin( + cancellationHandler: cancellationHandler, + dispatcherDelegate: dispatcherDelegate, + onResult: { suspendResult in + let result: Swift.Result = convertToResult(suspendResult: suspendResult) + + onResult(result) + + dispatcherDelegate.stop() + } + ) + + coroutine(suspendHandler) + } + } + + private static func convertToResult( + suspendResult: Skie.co_touchlab_skie__runtime_kotlin.Skie_SuspendResult.__Kotlin + ) -> Swift.Result { + if let suspendResult = suspendResult as? Skie.co_touchlab_skie__runtime_kotlin.Skie_SuspendResult.Success.__Kotlin { + if T.self == Swift.Void.self { + return .success(Swift.Void() as! T) + } else { + return .success(suspendResult.value as! T) + } + } else if let suspendResult = suspendResult as? Skie.co_touchlab_skie__runtime_kotlin.Skie_SuspendResult.Error.__Kotlin { + return .failure(suspendResult.error) + } else if suspendResult is Skie.co_touchlab_skie__runtime_kotlin.Skie_SuspendResult.Canceled.__Kotlin { + return .failure(_Concurrency.CancellationError()) + } else { + fatalError("Unknown suspend result. This is most likely a bug in SKIE.") + } + } + + private static func executeWithoutCancellation(dispatcher: _Concurrency.AsyncStream) async { + await _Concurrency.Task { + for await block in dispatcher { + block.run() + } + }.value + } + + private static func unwrap(result: Swift.Result?) throws -> T { + if let result = result { + switch result { + case .success(let value): + return value + case .failure(let error): + throw error + } + } else { + fatalError("Suspend execution ended without result! This is most likely a bug in SKIE.") + } + } +} + +// FILE: Skie/Skie.bridgeSubscriptionCount.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +func bridgeSubscriptionCount(_ subscriptionCount: Store6MutationsSkie.SkieSwiftStateFlow) -> Store6MutationsSkie.SkieSwiftStateFlow { + return subscriptionCount +} + +func bridgeSubscriptionCount(_ subscriptionCount: any Store6MutationsSkie.Kotlinx_coroutines_coreStateFlow) -> Store6MutationsSkie.SkieSwiftStateFlow { + return Store6MutationsSkie.SkieSwiftStateFlow(internal: subscriptionCount) +} + +// FILE: Stdlib/Stdlib.SuspendFunction1.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6MutationsSkie.KotlinSuspendFunction1 { + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func invoke(p1: Any?) async throws -> Any? { + return try await SwiftCoroutineDispatcher.dispatch { + Store6MutationsSkie.__SkieSuspendWrappersKt.Skie_Suspend__23__invoke(dispatchReceiver: self, p1: p1, suspendHandler: $0) + } + } + +} + +// FILE: Store6Core/Store6Core.Bookkeeper.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6MutationsSkie.Store6_coreBookkeeper { + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func advanceGlobalStaleWatermark() async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6MutationsSkie.__SkieSuspendWrappersKt.Skie_Suspend__25__advanceGlobalStaleWatermark(dispatchReceiver: self, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func advanceStaleWatermark(namespace: Store6MutationsSkie.Store6_coreStoreNamespace) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6MutationsSkie.__SkieSuspendWrappersKt.Skie_Suspend__26__advanceStaleWatermark(dispatchReceiver: self, namespace: namespace, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func forget(key: Store6MutationsSkie.Store6_coreStoreKey) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6MutationsSkie.__SkieSuspendWrappersKt.Skie_Suspend__27__forget(dispatchReceiver: self, key: key, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func forgetAll() async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6MutationsSkie.__SkieSuspendWrappersKt.Skie_Suspend__28__forgetAll(dispatchReceiver: self, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func forgetNamespace(namespace: Store6MutationsSkie.Store6_coreStoreNamespace) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6MutationsSkie.__SkieSuspendWrappersKt.Skie_Suspend__29__forgetNamespace(dispatchReceiver: self, namespace: namespace, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func markStale(key: Store6MutationsSkie.Store6_coreStoreKey) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6MutationsSkie.__SkieSuspendWrappersKt.Skie_Suspend__30__markStale(dispatchReceiver: self, key: key, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func recordFailure(key: Store6MutationsSkie.Store6_coreStoreKey, atEpochMillis: Swift.Int64) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6MutationsSkie.__SkieSuspendWrappersKt.Skie_Suspend__31__recordFailure(dispatchReceiver: self, key: key, atEpochMillis: atEpochMillis, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func recordSuccess(key: Store6MutationsSkie.Store6_coreStoreKey, meta: Store6MutationsSkie.Store6_coreStoreMeta) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6MutationsSkie.__SkieSuspendWrappersKt.Skie_Suspend__32__recordSuccess(dispatchReceiver: self, key: key, meta: meta, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func status(key: Store6MutationsSkie.Store6_coreStoreKey) async throws -> Store6MutationsSkie.Store6_coreKeyStatus? { + return try await SwiftCoroutineDispatcher.dispatch { + Store6MutationsSkie.__SkieSuspendWrappersKt.Skie_Suspend__33__status(dispatchReceiver: self, key: key, suspendHandler: $0) + } + } + +} + +// FILE: Store6Core/Store6Core.FetchPlan.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6MutationsSkie.Skie.Store6Core.FetchPlan { + + @frozen + public enum __Sealed : Swift.Hashable { + + case conditional(Store6MutationsSkie.Store6_coreFetchPlanConditional) + case fetch(Store6MutationsSkie.Store6_coreFetchPlanFetch) + case skip(Store6MutationsSkie.Store6_coreFetchPlanSkip) + + } + +} + +public func onEnum<__Sealed : Store6MutationsSkie.Store6_coreFetchPlan>(of sealed: __Sealed) -> Store6MutationsSkie.Skie.Store6Core.FetchPlan.__Sealed { + if let sealed = sealed as? Store6MutationsSkie.Store6_coreFetchPlanConditional { + return Store6MutationsSkie.Skie.Store6Core.FetchPlan.__Sealed.conditional(sealed) + } else if let sealed = sealed as? Store6MutationsSkie.Store6_coreFetchPlanFetch { + return Store6MutationsSkie.Skie.Store6Core.FetchPlan.__Sealed.fetch(sealed) + } else if let sealed = sealed as? Store6MutationsSkie.Store6_coreFetchPlanSkip { + return Store6MutationsSkie.Skie.Store6Core.FetchPlan.__Sealed.skip(sealed) + } else { + fatalError("Unknown subtype \(sealed). This error should not happen under normal circumstances since SirClass: Store6MutationsSkie.Store6_coreFetchPlan is sealed.") + } +} + +@_disfavoredOverload +public func onEnum<__Sealed : Store6MutationsSkie.Store6_coreFetchPlan>(of sealed: __Sealed?) -> Store6MutationsSkie.Skie.Store6Core.FetchPlan.__Sealed? { + if let sealed { + return onEnum(of: sealed) as Store6MutationsSkie.Skie.Store6Core.FetchPlan.__Sealed + } else { + return nil + } +} + +// FILE: Store6Core/Store6Core.Fetcher.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6MutationsSkie.Store6_coreFetcher { + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func fetch(key: Store6MutationsSkie.Store6_coreStoreKey, etag: Swift.String?) async throws -> Store6MutationsSkie.Store6_coreFetcherResult { + return try await SwiftCoroutineDispatcher.dispatch { + Store6MutationsSkie.__SkieSuspendWrappersKt.Skie_Suspend__24__fetch(dispatchReceiver: self, key: key, etag: etag, suspendHandler: $0) + } + } + +} + +// FILE: Store6Core/Store6Core.FetcherResult.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6MutationsSkie.Skie.Store6Core.FetcherResult { + + @frozen + public enum __Sealed : Swift.Hashable { + + case deleted(Store6MutationsSkie.Store6_coreFetcherResultDeleted) + case error(Store6MutationsSkie.Store6_coreFetcherResultError) + case notModified(Store6MutationsSkie.Store6_coreFetcherResultNotModified) + case success(Store6MutationsSkie.Store6_coreFetcherResultSuccess) + + } + +} + +public func onEnum<__Sealed : Store6MutationsSkie.Store6_coreFetcherResult>(of sealed: __Sealed) -> Store6MutationsSkie.Skie.Store6Core.FetcherResult.__Sealed { + if let sealed = sealed as? Store6MutationsSkie.Store6_coreFetcherResultDeleted { + return Store6MutationsSkie.Skie.Store6Core.FetcherResult.__Sealed.deleted(sealed) + } else if let sealed = sealed as? Store6MutationsSkie.Store6_coreFetcherResultError { + return Store6MutationsSkie.Skie.Store6Core.FetcherResult.__Sealed.error(sealed) + } else if let sealed = sealed as? Store6MutationsSkie.Store6_coreFetcherResultNotModified { + return Store6MutationsSkie.Skie.Store6Core.FetcherResult.__Sealed.notModified(sealed) + } else if let sealed = sealed as? Store6MutationsSkie.Store6_coreFetcherResultSuccess { + return Store6MutationsSkie.Skie.Store6Core.FetcherResult.__Sealed.success(sealed) + } else { + fatalError("Unknown subtype \(sealed). This error should not happen under normal circumstances since SirClass: Store6MutationsSkie.Store6_coreFetcherResult is sealed.") + } +} + +@_disfavoredOverload +public func onEnum<__Sealed : Store6MutationsSkie.Store6_coreFetcherResult>(of sealed: __Sealed?) -> Store6MutationsSkie.Skie.Store6Core.FetcherResult.__Sealed? { + if let sealed { + return onEnum(of: sealed) as Store6MutationsSkie.Skie.Store6Core.FetcherResult.__Sealed + } else { + return nil + } +} + +// FILE: Store6Core/Store6Core.Freshness.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6MutationsSkie.Skie.Store6Core.Freshness { + + @frozen + public enum __Sealed : Swift.Hashable { + + case cachedOrFetch(Store6MutationsSkie.Store6_coreFreshnessCachedOrFetch) + case localOnly(Store6MutationsSkie.Store6_coreFreshnessLocalOnly) + case maxAge(Store6MutationsSkie.Store6_coreFreshnessMaxAge) + case mustBeFresh(Store6MutationsSkie.Store6_coreFreshnessMustBeFresh) + case staleIfError(Store6MutationsSkie.Store6_coreFreshnessStaleIfError) + + } + +} + +public func onEnum<__Sealed : Store6MutationsSkie.Store6_coreFreshness>(of sealed: __Sealed) -> Store6MutationsSkie.Skie.Store6Core.Freshness.__Sealed { + if let sealed = sealed as? Store6MutationsSkie.Store6_coreFreshnessCachedOrFetch { + return Store6MutationsSkie.Skie.Store6Core.Freshness.__Sealed.cachedOrFetch(sealed) + } else if let sealed = sealed as? Store6MutationsSkie.Store6_coreFreshnessLocalOnly { + return Store6MutationsSkie.Skie.Store6Core.Freshness.__Sealed.localOnly(sealed) + } else if let sealed = sealed as? Store6MutationsSkie.Store6_coreFreshnessMaxAge { + return Store6MutationsSkie.Skie.Store6Core.Freshness.__Sealed.maxAge(sealed) + } else if let sealed = sealed as? Store6MutationsSkie.Store6_coreFreshnessMustBeFresh { + return Store6MutationsSkie.Skie.Store6Core.Freshness.__Sealed.mustBeFresh(sealed) + } else if let sealed = sealed as? Store6MutationsSkie.Store6_coreFreshnessStaleIfError { + return Store6MutationsSkie.Skie.Store6Core.Freshness.__Sealed.staleIfError(sealed) + } else { + fatalError("Unknown subtype \(sealed). This error should not happen under normal circumstances since SirClass: Store6MutationsSkie.Store6_coreFreshness is sealed.") + } +} + +@_disfavoredOverload +public func onEnum<__Sealed : Store6MutationsSkie.Store6_coreFreshness>(of sealed: __Sealed?) -> Store6MutationsSkie.Skie.Store6Core.Freshness.__Sealed? { + if let sealed { + return onEnum(of: sealed) as Store6MutationsSkie.Skie.Store6Core.Freshness.__Sealed + } else { + return nil + } +} + +// FILE: Store6Core/Store6Core.Origin.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +@frozen +public enum Store6_coreOrigin : Swift.Hashable, Swift.CaseIterable, Swift._ObjectiveCBridgeable { + + case memory + case sot + case fetcher + case overlay + + public var name: Swift.String { + return (self as _ObjectiveCType).name + } + + public var ordinal: Swift.Int32 { + return (self as _ObjectiveCType).ordinal + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6MutationsSkie.__Store6_coreOrigin, result: inout Store6MutationsSkie.Store6_coreOrigin?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6MutationsSkie.__Store6_coreOrigin, result: inout Store6MutationsSkie.Store6_coreOrigin?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6MutationsSkie.__Store6_coreOrigin?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6MutationsSkie.__Store6_coreOrigin { + switch self { + case .memory: return Store6MutationsSkie.__Store6_coreOrigin.memory as Store6MutationsSkie.__Store6_coreOrigin + case .sot: return Store6MutationsSkie.__Store6_coreOrigin.sot as Store6MutationsSkie.__Store6_coreOrigin + case .fetcher: return Store6MutationsSkie.__Store6_coreOrigin.fetcher as Store6MutationsSkie.__Store6_coreOrigin + case .overlay: return Store6MutationsSkie.__Store6_coreOrigin.overlay as Store6MutationsSkie.__Store6_coreOrigin + } + } + + private static func fromObjectiveC(_ source: Store6MutationsSkie.__Store6_coreOrigin?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6MutationsSkie.Store6_coreOrigin") + } + if source == Store6MutationsSkie.__Store6_coreOrigin.memory as Store6MutationsSkie.__Store6_coreOrigin { + return .memory + } else if source == Store6MutationsSkie.__Store6_coreOrigin.sot as Store6MutationsSkie.__Store6_coreOrigin { + return .sot + } else if source == Store6MutationsSkie.__Store6_coreOrigin.fetcher as Store6MutationsSkie.__Store6_coreOrigin { + return .fetcher + } else if source == Store6MutationsSkie.__Store6_coreOrigin.overlay as Store6MutationsSkie.__Store6_coreOrigin { + return .overlay + } else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6MutationsSkie.__Store6_coreOrigin") + } + } + + public typealias _ObjectiveCType = Store6MutationsSkie.__Store6_coreOrigin + +} + +extension Store6MutationsSkie.Store6_coreOrigin { + + public func toKotlinEnum() -> Store6MutationsSkie.__Store6_coreOrigin { + return _bridgeToObjectiveC() + } + +} + +extension Store6MutationsSkie.__Store6_coreOrigin { + + public func toSwiftEnum() -> Store6MutationsSkie.Store6_coreOrigin { + return Store6MutationsSkie.Store6_coreOrigin._unconditionallyBridgeFromObjectiveC(self) + } + +} + +// FILE: Store6Core/Store6Core.SourceOfTruth.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6MutationsSkie.Store6_coreSourceOfTruth { + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func delete(key: Store6MutationsSkie.Store6_coreStoreKey) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6MutationsSkie.__SkieSuspendWrappersKt.Skie_Suspend__18__delete(dispatchReceiver: self, key: key, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func deleteAll() async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6MutationsSkie.__SkieSuspendWrappersKt.Skie_Suspend__19__deleteAll(dispatchReceiver: self, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func deleteNamespace(namespace: Store6MutationsSkie.Store6_coreStoreNamespace) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6MutationsSkie.__SkieSuspendWrappersKt.Skie_Suspend__20__deleteNamespace(dispatchReceiver: self, namespace: namespace, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func write(key: Store6MutationsSkie.Store6_coreStoreKey, value: Any) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6MutationsSkie.__SkieSuspendWrappersKt.Skie_Suspend__21__write(dispatchReceiver: self, key: key, value: value, suspendHandler: $0) + } + } + +} + +// FILE: Store6Core/Store6Core.Store.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6MutationsSkie.Store6_coreStore { + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func clear(key: Store6MutationsSkie.Store6_coreStoreKey) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6MutationsSkie.__SkieSuspendWrappersKt.Skie_Suspend__11__clear(dispatchReceiver: self, key: key, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func clearAll() async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6MutationsSkie.__SkieSuspendWrappersKt.Skie_Suspend__12__clearAll(dispatchReceiver: self, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func clearNamespace(namespace: Store6MutationsSkie.Store6_coreStoreNamespace) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6MutationsSkie.__SkieSuspendWrappersKt.Skie_Suspend__13__clearNamespace(dispatchReceiver: self, namespace: namespace, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func get(key: Store6MutationsSkie.Store6_coreStoreKey, freshness: Store6MutationsSkie.Store6_coreFreshness) async throws -> Any { + return try await SwiftCoroutineDispatcher.dispatch { + Store6MutationsSkie.__SkieSuspendWrappersKt.Skie_Suspend__14__get(dispatchReceiver: self, key: key, freshness: freshness, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func invalidate(key: Store6MutationsSkie.Store6_coreStoreKey) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6MutationsSkie.__SkieSuspendWrappersKt.Skie_Suspend__15__invalidate(dispatchReceiver: self, key: key, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func invalidateAll() async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6MutationsSkie.__SkieSuspendWrappersKt.Skie_Suspend__16__invalidateAll(dispatchReceiver: self, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func invalidateNamespace(namespace: Store6MutationsSkie.Store6_coreStoreNamespace) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6MutationsSkie.__SkieSuspendWrappersKt.Skie_Suspend__17__invalidateNamespace(dispatchReceiver: self, namespace: namespace, suspendHandler: $0) + } + } + +} + +// FILE: Store6Core/Store6Core.StoreError.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6MutationsSkie.Skie.Store6Core.StoreError { + + @frozen + public enum __Sealed : Swift.Hashable { + + case conflict(Store6MutationsSkie.Store6_coreStoreError.Conflict) + case conversion(Store6MutationsSkie.Store6_coreStoreError.Conversion) + case fetch(Store6MutationsSkie.Store6_coreStoreError.Fetch) + case freshnessUnsatisfiable(Store6MutationsSkie.Store6_coreStoreError.FreshnessUnsatisfiable) + case missing(Store6MutationsSkie.Store6_coreStoreError.Missing) + case persistence(Store6MutationsSkie.Store6_coreStoreError.Persistence) + + } + +} + +public func onEnum<__Sealed : Store6MutationsSkie.Store6_coreStoreError>(of sealed: __Sealed) -> Store6MutationsSkie.Skie.Store6Core.StoreError.__Sealed { + if let sealed = sealed as? Store6MutationsSkie.Store6_coreStoreError.Conflict { + return Store6MutationsSkie.Skie.Store6Core.StoreError.__Sealed.conflict(sealed) + } else if let sealed = sealed as? Store6MutationsSkie.Store6_coreStoreError.Conversion { + return Store6MutationsSkie.Skie.Store6Core.StoreError.__Sealed.conversion(sealed) + } else if let sealed = sealed as? Store6MutationsSkie.Store6_coreStoreError.Fetch { + return Store6MutationsSkie.Skie.Store6Core.StoreError.__Sealed.fetch(sealed) + } else if let sealed = sealed as? Store6MutationsSkie.Store6_coreStoreError.FreshnessUnsatisfiable { + return Store6MutationsSkie.Skie.Store6Core.StoreError.__Sealed.freshnessUnsatisfiable(sealed) + } else if let sealed = sealed as? Store6MutationsSkie.Store6_coreStoreError.Missing { + return Store6MutationsSkie.Skie.Store6Core.StoreError.__Sealed.missing(sealed) + } else if let sealed = sealed as? Store6MutationsSkie.Store6_coreStoreError.Persistence { + return Store6MutationsSkie.Skie.Store6Core.StoreError.__Sealed.persistence(sealed) + } else { + fatalError("Unknown subtype \(sealed). This error should not happen under normal circumstances since SirClass: Store6MutationsSkie.Store6_coreStoreError is sealed.") + } +} + +@_disfavoredOverload +public func onEnum<__Sealed : Store6MutationsSkie.Store6_coreStoreError>(of sealed: __Sealed?) -> Store6MutationsSkie.Skie.Store6Core.StoreError.__Sealed? { + if let sealed { + return onEnum(of: sealed) as Store6MutationsSkie.Skie.Store6Core.StoreError.__Sealed + } else { + return nil + } +} + +// FILE: Store6Core/Store6Core.StoreResult.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6MutationsSkie.Skie.Store6Core.StoreResult { + + @frozen + public enum __Sealed : Swift.Hashable { + + case data(Store6MutationsSkie.Store6_coreStoreResultData) + case error(Store6MutationsSkie.Store6_coreStoreResultError) + case loading(Store6MutationsSkie.Store6_coreStoreResultLoading) + case revalidated(Store6MutationsSkie.Store6_coreStoreResultRevalidated) + + } + +} + +public func onEnum<__Sealed : Store6MutationsSkie.Store6_coreStoreResult>(of sealed: __Sealed) -> Store6MutationsSkie.Skie.Store6Core.StoreResult.__Sealed { + if let sealed = sealed as? Store6MutationsSkie.Store6_coreStoreResultData { + return Store6MutationsSkie.Skie.Store6Core.StoreResult.__Sealed.data(sealed) + } else if let sealed = sealed as? Store6MutationsSkie.Store6_coreStoreResultError { + return Store6MutationsSkie.Skie.Store6Core.StoreResult.__Sealed.error(sealed) + } else if let sealed = sealed as? Store6MutationsSkie.Store6_coreStoreResultLoading { + return Store6MutationsSkie.Skie.Store6Core.StoreResult.__Sealed.loading(sealed) + } else if let sealed = sealed as? Store6MutationsSkie.Store6_coreStoreResultRevalidated { + return Store6MutationsSkie.Skie.Store6Core.StoreResult.__Sealed.revalidated(sealed) + } else { + fatalError("Unknown subtype \(sealed). This error should not happen under normal circumstances since SirClass: Store6MutationsSkie.Store6_coreStoreResult is sealed.") + } +} + +@_disfavoredOverload +public func onEnum<__Sealed : Store6MutationsSkie.Store6_coreStoreResult>(of sealed: __Sealed?) -> Store6MutationsSkie.Skie.Store6Core.StoreResult.__Sealed? { + if let sealed { + return onEnum(of: sealed) as Store6MutationsSkie.Skie.Store6Core.StoreResult.__Sealed + } else { + return nil + } +} + +// FILE: Store6Mutations/Store6Mutations.MutationAck.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6MutationsSkie.Skie.Store6Mutations.MutationAck { + + @frozen + public enum __Sealed : Swift.Hashable { + + case mutationAbsentAck(Store6MutationsSkie.MutationAbsentAck) + case mutationPresentAck(Store6MutationsSkie.MutationPresentAck) + + } + +} + +public func onEnum<__Sealed : Store6MutationsSkie.MutationAck>(of sealed: __Sealed) -> Store6MutationsSkie.Skie.Store6Mutations.MutationAck.__Sealed { + if let sealed = sealed as? Store6MutationsSkie.MutationAbsentAck { + return Store6MutationsSkie.Skie.Store6Mutations.MutationAck.__Sealed.mutationAbsentAck(sealed) + } else if let sealed = sealed as? Store6MutationsSkie.MutationPresentAck { + return Store6MutationsSkie.Skie.Store6Mutations.MutationAck.__Sealed.mutationPresentAck(sealed) + } else { + fatalError("Unknown subtype \(sealed). This error should not happen under normal circumstances since SirClass: Store6MutationsSkie.MutationAck is sealed.") + } +} + +@_disfavoredOverload +public func onEnum<__Sealed : Store6MutationsSkie.MutationAck>(of sealed: __Sealed?) -> Store6MutationsSkie.Skie.Store6Mutations.MutationAck.__Sealed? { + if let sealed { + return onEnum(of: sealed) as Store6MutationsSkie.Skie.Store6Mutations.MutationAck.__Sealed + } else { + return nil + } +} + +// FILE: Store6Mutations/Store6Mutations.MutationAliasState.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +@frozen +public enum MutationAliasState : Swift.Hashable, Swift.CaseIterable, Swift._ObjectiveCBridgeable { + + case pending + case active + + public var name: Swift.String { + return (self as _ObjectiveCType).name + } + + public var ordinal: Swift.Int32 { + return (self as _ObjectiveCType).ordinal + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6MutationsSkie.__MutationAliasState, result: inout Store6MutationsSkie.MutationAliasState?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6MutationsSkie.__MutationAliasState, result: inout Store6MutationsSkie.MutationAliasState?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6MutationsSkie.__MutationAliasState?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6MutationsSkie.__MutationAliasState { + switch self { + case .pending: return Store6MutationsSkie.__MutationAliasState.pending as Store6MutationsSkie.__MutationAliasState + case .active: return Store6MutationsSkie.__MutationAliasState.active as Store6MutationsSkie.__MutationAliasState + } + } + + private static func fromObjectiveC(_ source: Store6MutationsSkie.__MutationAliasState?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6MutationsSkie.MutationAliasState") + } + if source == Store6MutationsSkie.__MutationAliasState.pending as Store6MutationsSkie.__MutationAliasState { + return .pending + } else if source == Store6MutationsSkie.__MutationAliasState.active as Store6MutationsSkie.__MutationAliasState { + return .active + } else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6MutationsSkie.__MutationAliasState") + } + } + + public typealias _ObjectiveCType = Store6MutationsSkie.__MutationAliasState + +} + +extension Store6MutationsSkie.MutationAliasState { + + public func toKotlinEnum() -> Store6MutationsSkie.__MutationAliasState { + return _bridgeToObjectiveC() + } + +} + +extension Store6MutationsSkie.__MutationAliasState { + + public func toSwiftEnum() -> Store6MutationsSkie.MutationAliasState { + return Store6MutationsSkie.MutationAliasState._unconditionallyBridgeFromObjectiveC(self) + } + +} + +// FILE: Store6Mutations/Store6Mutations.MutationConflictResolution.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6MutationsSkie.Skie.Store6Mutations.MutationConflictResolution { + + @frozen + public enum __Sealed : Swift.Hashable { + + case retry(Store6MutationsSkie.MutationConflictResolutionRetry) + case serverWins(Store6MutationsSkie.MutationConflictResolutionServerWins) + + } + +} + +public func onEnum<__Sealed : Store6MutationsSkie.MutationConflictResolution>(of sealed: __Sealed) -> Store6MutationsSkie.Skie.Store6Mutations.MutationConflictResolution.__Sealed { + if let sealed = sealed as? Store6MutationsSkie.MutationConflictResolutionRetry { + return Store6MutationsSkie.Skie.Store6Mutations.MutationConflictResolution.__Sealed.retry(sealed) + } else if let sealed = sealed as? Store6MutationsSkie.MutationConflictResolutionServerWins { + return Store6MutationsSkie.Skie.Store6Mutations.MutationConflictResolution.__Sealed.serverWins(sealed) + } else { + fatalError("Unknown subtype \(sealed). This error should not happen under normal circumstances since SirClass: Store6MutationsSkie.MutationConflictResolution is sealed.") + } +} + +@_disfavoredOverload +public func onEnum<__Sealed : Store6MutationsSkie.MutationConflictResolution>(of sealed: __Sealed?) -> Store6MutationsSkie.Skie.Store6Mutations.MutationConflictResolution.__Sealed? { + if let sealed { + return onEnum(of: sealed) as Store6MutationsSkie.Skie.Store6Mutations.MutationConflictResolution.__Sealed + } else { + return nil + } +} + +// FILE: Store6Mutations/Store6Mutations.MutationEffectDisposition.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +@frozen +public enum MutationEffectDisposition : Swift.Hashable, Swift.CaseIterable, + Swift._ObjectiveCBridgeable { + + case pending + case applied + case skipped + + public var name: Swift.String { + return (self as _ObjectiveCType).name + } + + public var ordinal: Swift.Int32 { + return (self as _ObjectiveCType).ordinal + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6MutationsSkie.__MutationEffectDisposition, result: inout Store6MutationsSkie.MutationEffectDisposition?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6MutationsSkie.__MutationEffectDisposition, result: inout Store6MutationsSkie.MutationEffectDisposition?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6MutationsSkie.__MutationEffectDisposition?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6MutationsSkie.__MutationEffectDisposition { + switch self { + case .pending: return Store6MutationsSkie.__MutationEffectDisposition.pending as Store6MutationsSkie.__MutationEffectDisposition + case .applied: return Store6MutationsSkie.__MutationEffectDisposition.applied as Store6MutationsSkie.__MutationEffectDisposition + case .skipped: return Store6MutationsSkie.__MutationEffectDisposition.skipped as Store6MutationsSkie.__MutationEffectDisposition + } + } + + private static func fromObjectiveC(_ source: Store6MutationsSkie.__MutationEffectDisposition?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6MutationsSkie.MutationEffectDisposition") + } + if source == Store6MutationsSkie.__MutationEffectDisposition.pending as Store6MutationsSkie.__MutationEffectDisposition { + return .pending + } else if source == Store6MutationsSkie.__MutationEffectDisposition.applied as Store6MutationsSkie.__MutationEffectDisposition { + return .applied + } else if source == Store6MutationsSkie.__MutationEffectDisposition.skipped as Store6MutationsSkie.__MutationEffectDisposition { + return .skipped + } else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6MutationsSkie.__MutationEffectDisposition") + } + } + + public typealias _ObjectiveCType = Store6MutationsSkie.__MutationEffectDisposition + +} + +extension Store6MutationsSkie.MutationEffectDisposition { + + public func toKotlinEnum() -> Store6MutationsSkie.__MutationEffectDisposition { + return _bridgeToObjectiveC() + } + +} + +extension Store6MutationsSkie.__MutationEffectDisposition { + + public func toSwiftEnum() -> Store6MutationsSkie.MutationEffectDisposition { + return Store6MutationsSkie.MutationEffectDisposition._unconditionallyBridgeFromObjectiveC(self) + } + +} + +// FILE: Store6Mutations/Store6Mutations.MutationEffectKind.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +@frozen +public enum MutationEffectKind : Swift.Hashable, Swift.CaseIterable, Swift._ObjectiveCBridgeable { + + case key + case namespace + + public var name: Swift.String { + return (self as _ObjectiveCType).name + } + + public var ordinal: Swift.Int32 { + return (self as _ObjectiveCType).ordinal + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6MutationsSkie.__MutationEffectKind, result: inout Store6MutationsSkie.MutationEffectKind?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6MutationsSkie.__MutationEffectKind, result: inout Store6MutationsSkie.MutationEffectKind?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6MutationsSkie.__MutationEffectKind?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6MutationsSkie.__MutationEffectKind { + switch self { + case .key: return Store6MutationsSkie.__MutationEffectKind.key as Store6MutationsSkie.__MutationEffectKind + case .namespace: return Store6MutationsSkie.__MutationEffectKind.namespace as Store6MutationsSkie.__MutationEffectKind + } + } + + private static func fromObjectiveC(_ source: Store6MutationsSkie.__MutationEffectKind?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6MutationsSkie.MutationEffectKind") + } + if source == Store6MutationsSkie.__MutationEffectKind.key as Store6MutationsSkie.__MutationEffectKind { + return .key + } else if source == Store6MutationsSkie.__MutationEffectKind.namespace as Store6MutationsSkie.__MutationEffectKind { + return .namespace + } else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6MutationsSkie.__MutationEffectKind") + } + } + + public typealias _ObjectiveCType = Store6MutationsSkie.__MutationEffectKind + +} + +extension Store6MutationsSkie.MutationEffectKind { + + public func toKotlinEnum() -> Store6MutationsSkie.__MutationEffectKind { + return _bridgeToObjectiveC() + } + +} + +extension Store6MutationsSkie.__MutationEffectKind { + + public func toSwiftEnum() -> Store6MutationsSkie.MutationEffectKind { + return Store6MutationsSkie.MutationEffectKind._unconditionallyBridgeFromObjectiveC(self) + } + +} + +// FILE: Store6Mutations/Store6Mutations.MutationEvent.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6MutationsSkie.Skie.Store6Mutations.MutationEvent { + + @frozen + public enum __Sealed { + + case mutationCheckpointConfirmed(Store6MutationsSkie.MutationCheckpointConfirmed) + case mutationCheckpointFailed(Store6MutationsSkie.MutationCheckpointFailed) + case mutationIntentEvent(Store6MutationsSkie.MutationIntentEvent) + + } + +} + +public func onEnum<__Sealed : Store6MutationsSkie.MutationEvent>(of sealed: __Sealed) -> Store6MutationsSkie.Skie.Store6Mutations.MutationEvent.__Sealed { + if let sealed = sealed as? Store6MutationsSkie.MutationCheckpointConfirmed { + return Store6MutationsSkie.Skie.Store6Mutations.MutationEvent.__Sealed.mutationCheckpointConfirmed(sealed) + } else if let sealed = sealed as? Store6MutationsSkie.MutationCheckpointFailed { + return Store6MutationsSkie.Skie.Store6Mutations.MutationEvent.__Sealed.mutationCheckpointFailed(sealed) + } else if let sealed = sealed as? Store6MutationsSkie.MutationIntentEvent { + return Store6MutationsSkie.Skie.Store6Mutations.MutationEvent.__Sealed.mutationIntentEvent(sealed) + } else { + fatalError("Unknown subtype \(sealed). This error should not happen under normal circumstances since SirClass: Store6MutationsSkie.MutationEvent is sealed.") + } +} + +@_disfavoredOverload +public func onEnum<__Sealed : Store6MutationsSkie.MutationEvent>(of sealed: __Sealed?) -> Store6MutationsSkie.Skie.Store6Mutations.MutationEvent.__Sealed? { + if let sealed { + return onEnum(of: sealed) as Store6MutationsSkie.Skie.Store6Mutations.MutationEvent.__Sealed + } else { + return nil + } +} + +// FILE: Store6Mutations/Store6Mutations.MutationExecutionPhase.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +@frozen +public enum MutationExecutionPhase : Swift.Hashable, Swift.CaseIterable, + Swift._ObjectiveCBridgeable { + + case unprepared + case ready + case inflight + case refreshRequired + case acked + case effectsPending + case parked + case retired + + public var name: Swift.String { + return (self as _ObjectiveCType).name + } + + public var ordinal: Swift.Int32 { + return (self as _ObjectiveCType).ordinal + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6MutationsSkie.__MutationExecutionPhase, result: inout Store6MutationsSkie.MutationExecutionPhase?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6MutationsSkie.__MutationExecutionPhase, result: inout Store6MutationsSkie.MutationExecutionPhase?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6MutationsSkie.__MutationExecutionPhase?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6MutationsSkie.__MutationExecutionPhase { + switch self { + case .unprepared: return Store6MutationsSkie.__MutationExecutionPhase.unprepared as Store6MutationsSkie.__MutationExecutionPhase + case .ready: return Store6MutationsSkie.__MutationExecutionPhase.ready as Store6MutationsSkie.__MutationExecutionPhase + case .inflight: return Store6MutationsSkie.__MutationExecutionPhase.inflight as Store6MutationsSkie.__MutationExecutionPhase + case .refreshRequired: return Store6MutationsSkie.__MutationExecutionPhase.refreshRequired as Store6MutationsSkie.__MutationExecutionPhase + case .acked: return Store6MutationsSkie.__MutationExecutionPhase.acked as Store6MutationsSkie.__MutationExecutionPhase + case .effectsPending: return Store6MutationsSkie.__MutationExecutionPhase.effectsPending as Store6MutationsSkie.__MutationExecutionPhase + case .parked: return Store6MutationsSkie.__MutationExecutionPhase.parked as Store6MutationsSkie.__MutationExecutionPhase + case .retired: return Store6MutationsSkie.__MutationExecutionPhase.retired as Store6MutationsSkie.__MutationExecutionPhase + } + } + + private static func fromObjectiveC(_ source: Store6MutationsSkie.__MutationExecutionPhase?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6MutationsSkie.MutationExecutionPhase") + } + if source == Store6MutationsSkie.__MutationExecutionPhase.unprepared as Store6MutationsSkie.__MutationExecutionPhase { + return .unprepared + } else if source == Store6MutationsSkie.__MutationExecutionPhase.ready as Store6MutationsSkie.__MutationExecutionPhase { + return .ready + } else if source == Store6MutationsSkie.__MutationExecutionPhase.inflight as Store6MutationsSkie.__MutationExecutionPhase { + return .inflight + } else if source == Store6MutationsSkie.__MutationExecutionPhase.refreshRequired as Store6MutationsSkie.__MutationExecutionPhase { + return .refreshRequired + } else if source == Store6MutationsSkie.__MutationExecutionPhase.acked as Store6MutationsSkie.__MutationExecutionPhase { + return .acked + } else if source == Store6MutationsSkie.__MutationExecutionPhase.effectsPending as Store6MutationsSkie.__MutationExecutionPhase { + return .effectsPending + } else if source == Store6MutationsSkie.__MutationExecutionPhase.parked as Store6MutationsSkie.__MutationExecutionPhase { + return .parked + } else if source == Store6MutationsSkie.__MutationExecutionPhase.retired as Store6MutationsSkie.__MutationExecutionPhase { + return .retired + } else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6MutationsSkie.__MutationExecutionPhase") + } + } + + public typealias _ObjectiveCType = Store6MutationsSkie.__MutationExecutionPhase + +} + +extension Store6MutationsSkie.MutationExecutionPhase { + + public func toKotlinEnum() -> Store6MutationsSkie.__MutationExecutionPhase { + return _bridgeToObjectiveC() + } + +} + +extension Store6MutationsSkie.__MutationExecutionPhase { + + public func toSwiftEnum() -> Store6MutationsSkie.MutationExecutionPhase { + return Store6MutationsSkie.MutationExecutionPhase._unconditionallyBridgeFromObjectiveC(self) + } + +} + +// FILE: Store6Mutations/Store6Mutations.MutationFailureKind.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +@frozen +public enum MutationFailureKind : Swift.Hashable, Swift.CaseIterable, Swift._ObjectiveCBridgeable { + + case identity + case codec + case projection + case `protocol` + case conflict + case transport + case adoption + case effect + case persistence + + public var name: Swift.String { + return (self as _ObjectiveCType).name + } + + public var ordinal: Swift.Int32 { + return (self as _ObjectiveCType).ordinal + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6MutationsSkie.__MutationFailureKind, result: inout Store6MutationsSkie.MutationFailureKind?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6MutationsSkie.__MutationFailureKind, result: inout Store6MutationsSkie.MutationFailureKind?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6MutationsSkie.__MutationFailureKind?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6MutationsSkie.__MutationFailureKind { + switch self { + case .identity: return Store6MutationsSkie.__MutationFailureKind.identity as Store6MutationsSkie.__MutationFailureKind + case .codec: return Store6MutationsSkie.__MutationFailureKind.codec as Store6MutationsSkie.__MutationFailureKind + case .projection: return Store6MutationsSkie.__MutationFailureKind.projection as Store6MutationsSkie.__MutationFailureKind + case .`protocol`: return Store6MutationsSkie.__MutationFailureKind.`protocol` as Store6MutationsSkie.__MutationFailureKind + case .conflict: return Store6MutationsSkie.__MutationFailureKind.conflict as Store6MutationsSkie.__MutationFailureKind + case .transport: return Store6MutationsSkie.__MutationFailureKind.transport as Store6MutationsSkie.__MutationFailureKind + case .adoption: return Store6MutationsSkie.__MutationFailureKind.adoption as Store6MutationsSkie.__MutationFailureKind + case .effect: return Store6MutationsSkie.__MutationFailureKind.effect as Store6MutationsSkie.__MutationFailureKind + case .persistence: return Store6MutationsSkie.__MutationFailureKind.persistence as Store6MutationsSkie.__MutationFailureKind + } + } + + private static func fromObjectiveC(_ source: Store6MutationsSkie.__MutationFailureKind?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6MutationsSkie.MutationFailureKind") + } + if source == Store6MutationsSkie.__MutationFailureKind.identity as Store6MutationsSkie.__MutationFailureKind { + return .identity + } else if source == Store6MutationsSkie.__MutationFailureKind.codec as Store6MutationsSkie.__MutationFailureKind { + return .codec + } else if source == Store6MutationsSkie.__MutationFailureKind.projection as Store6MutationsSkie.__MutationFailureKind { + return .projection + } else if source == Store6MutationsSkie.__MutationFailureKind.`protocol` as Store6MutationsSkie.__MutationFailureKind { + return .`protocol` + } else if source == Store6MutationsSkie.__MutationFailureKind.conflict as Store6MutationsSkie.__MutationFailureKind { + return .conflict + } else if source == Store6MutationsSkie.__MutationFailureKind.transport as Store6MutationsSkie.__MutationFailureKind { + return .transport + } else if source == Store6MutationsSkie.__MutationFailureKind.adoption as Store6MutationsSkie.__MutationFailureKind { + return .adoption + } else if source == Store6MutationsSkie.__MutationFailureKind.effect as Store6MutationsSkie.__MutationFailureKind { + return .effect + } else if source == Store6MutationsSkie.__MutationFailureKind.persistence as Store6MutationsSkie.__MutationFailureKind { + return .persistence + } else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6MutationsSkie.__MutationFailureKind") + } + } + + public typealias _ObjectiveCType = Store6MutationsSkie.__MutationFailureKind + +} + +extension Store6MutationsSkie.MutationFailureKind { + + public func toKotlinEnum() -> Store6MutationsSkie.__MutationFailureKind { + return _bridgeToObjectiveC() + } + +} + +extension Store6MutationsSkie.__MutationFailureKind { + + public func toSwiftEnum() -> Store6MutationsSkie.MutationFailureKind { + return Store6MutationsSkie.MutationFailureKind._unconditionallyBridgeFromObjectiveC(self) + } + +} + +// FILE: Store6Mutations/Store6Mutations.MutationIntentEvent.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6MutationsSkie.Skie.Store6Mutations.MutationIntentEvent { + + @frozen + public enum __Sealed : Swift.Hashable { + + case mutationAcknowledged(Store6MutationsSkie.MutationAcknowledged) + case mutationAdopted(Store6MutationsSkie.MutationAdopted) + case mutationAttempted(Store6MutationsSkie.MutationAttempted) + case mutationConflictObserved(Store6MutationsSkie.MutationConflictObserved) + case mutationEffectApplied(Store6MutationsSkie.MutationEffectApplied) + case mutationEffectSkipped(Store6MutationsSkie.MutationEffectSkipped) + case mutationEnqueued(Store6MutationsSkie.MutationEnqueued) + case mutationFailed(Store6MutationsSkie.MutationFailed) + case mutationParked(Store6MutationsSkie.MutationParked) + case mutationRetired(Store6MutationsSkie.MutationRetired) + + } + +} + +public func onEnum<__Sealed : Store6MutationsSkie.MutationIntentEvent>(of sealed: __Sealed) -> Store6MutationsSkie.Skie.Store6Mutations.MutationIntentEvent.__Sealed { + if let sealed = sealed as? Store6MutationsSkie.MutationAcknowledged { + return Store6MutationsSkie.Skie.Store6Mutations.MutationIntentEvent.__Sealed.mutationAcknowledged(sealed) + } else if let sealed = sealed as? Store6MutationsSkie.MutationAdopted { + return Store6MutationsSkie.Skie.Store6Mutations.MutationIntentEvent.__Sealed.mutationAdopted(sealed) + } else if let sealed = sealed as? Store6MutationsSkie.MutationAttempted { + return Store6MutationsSkie.Skie.Store6Mutations.MutationIntentEvent.__Sealed.mutationAttempted(sealed) + } else if let sealed = sealed as? Store6MutationsSkie.MutationConflictObserved { + return Store6MutationsSkie.Skie.Store6Mutations.MutationIntentEvent.__Sealed.mutationConflictObserved(sealed) + } else if let sealed = sealed as? Store6MutationsSkie.MutationEffectApplied { + return Store6MutationsSkie.Skie.Store6Mutations.MutationIntentEvent.__Sealed.mutationEffectApplied(sealed) + } else if let sealed = sealed as? Store6MutationsSkie.MutationEffectSkipped { + return Store6MutationsSkie.Skie.Store6Mutations.MutationIntentEvent.__Sealed.mutationEffectSkipped(sealed) + } else if let sealed = sealed as? Store6MutationsSkie.MutationEnqueued { + return Store6MutationsSkie.Skie.Store6Mutations.MutationIntentEvent.__Sealed.mutationEnqueued(sealed) + } else if let sealed = sealed as? Store6MutationsSkie.MutationFailed { + return Store6MutationsSkie.Skie.Store6Mutations.MutationIntentEvent.__Sealed.mutationFailed(sealed) + } else if let sealed = sealed as? Store6MutationsSkie.MutationParked { + return Store6MutationsSkie.Skie.Store6Mutations.MutationIntentEvent.__Sealed.mutationParked(sealed) + } else if let sealed = sealed as? Store6MutationsSkie.MutationRetired { + return Store6MutationsSkie.Skie.Store6Mutations.MutationIntentEvent.__Sealed.mutationRetired(sealed) + } else { + fatalError("Unknown subtype \(sealed). This error should not happen under normal circumstances since SirClass: Store6MutationsSkie.MutationIntentEvent is sealed.") + } +} + +@_disfavoredOverload +public func onEnum<__Sealed : Store6MutationsSkie.MutationIntentEvent>(of sealed: __Sealed?) -> Store6MutationsSkie.Skie.Store6Mutations.MutationIntentEvent.__Sealed? { + if let sealed { + return onEnum(of: sealed) as Store6MutationsSkie.Skie.Store6Mutations.MutationIntentEvent.__Sealed + } else { + return nil + } +} + +// FILE: Store6Mutations/Store6Mutations.MutationJournalStorage.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6MutationsSkie.MutationJournalStorage { + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func transaction(block: @escaping (Store6MutationsSkie.MutationJournalTransaction) -> Any?) async throws -> Any? { + return try await SwiftCoroutineDispatcher.dispatch { + Store6MutationsSkie.__SkieSuspendWrappersKt.Skie_Suspend__22__transaction(dispatchReceiver: self, block: block, suspendHandler: $0) + } + } + +} + +// FILE: Store6Mutations/Store6Mutations.MutationKeyResolver.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6MutationsSkie.MutationKeyResolver { + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func resolve(identity: Store6MutationsSkie.MutationKeyIdentity) async throws -> Store6MutationsSkie.Store6_coreStoreKey? { + return try await SwiftCoroutineDispatcher.dispatch { + Store6MutationsSkie.__SkieSuspendWrappersKt.Skie_Suspend__0__resolve(dispatchReceiver: self, identity: identity, suspendHandler: $0) + } + } + +} + +// FILE: Store6Mutations/Store6Mutations.MutationPendingState.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +@frozen +public enum MutationPendingState : Swift.Hashable, Swift.CaseIterable, Swift._ObjectiveCBridgeable { + + case pending + case inflight + case refreshing + case adopting + case applyingEffects + + public var name: Swift.String { + return (self as _ObjectiveCType).name + } + + public var ordinal: Swift.Int32 { + return (self as _ObjectiveCType).ordinal + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6MutationsSkie.__MutationPendingState, result: inout Store6MutationsSkie.MutationPendingState?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6MutationsSkie.__MutationPendingState, result: inout Store6MutationsSkie.MutationPendingState?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6MutationsSkie.__MutationPendingState?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6MutationsSkie.__MutationPendingState { + switch self { + case .pending: return Store6MutationsSkie.__MutationPendingState.pending as Store6MutationsSkie.__MutationPendingState + case .inflight: return Store6MutationsSkie.__MutationPendingState.inflight as Store6MutationsSkie.__MutationPendingState + case .refreshing: return Store6MutationsSkie.__MutationPendingState.refreshing as Store6MutationsSkie.__MutationPendingState + case .adopting: return Store6MutationsSkie.__MutationPendingState.adopting as Store6MutationsSkie.__MutationPendingState + case .applyingEffects: return Store6MutationsSkie.__MutationPendingState.applyingEffects as Store6MutationsSkie.__MutationPendingState + } + } + + private static func fromObjectiveC(_ source: Store6MutationsSkie.__MutationPendingState?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6MutationsSkie.MutationPendingState") + } + if source == Store6MutationsSkie.__MutationPendingState.pending as Store6MutationsSkie.__MutationPendingState { + return .pending + } else if source == Store6MutationsSkie.__MutationPendingState.inflight as Store6MutationsSkie.__MutationPendingState { + return .inflight + } else if source == Store6MutationsSkie.__MutationPendingState.refreshing as Store6MutationsSkie.__MutationPendingState { + return .refreshing + } else if source == Store6MutationsSkie.__MutationPendingState.adopting as Store6MutationsSkie.__MutationPendingState { + return .adopting + } else if source == Store6MutationsSkie.__MutationPendingState.applyingEffects as Store6MutationsSkie.__MutationPendingState { + return .applyingEffects + } else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6MutationsSkie.__MutationPendingState") + } + } + + public typealias _ObjectiveCType = Store6MutationsSkie.__MutationPendingState + +} + +extension Store6MutationsSkie.MutationPendingState { + + public func toKotlinEnum() -> Store6MutationsSkie.__MutationPendingState { + return _bridgeToObjectiveC() + } + +} + +extension Store6MutationsSkie.__MutationPendingState { + + public func toSwiftEnum() -> Store6MutationsSkie.MutationPendingState { + return Store6MutationsSkie.MutationPendingState._unconditionallyBridgeFromObjectiveC(self) + } + +} + +// FILE: Store6Mutations/Store6Mutations.MutationPresence.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6MutationsSkie.Skie.Store6Mutations.MutationPresence { + + @frozen + public enum __Sealed : Swift.Hashable { + + case absent(Store6MutationsSkie.MutationPresenceAbsent) + case present(Store6MutationsSkie.MutationPresencePresent) + + } + +} + +public func onEnum<__Sealed : Store6MutationsSkie.MutationPresence>(of sealed: __Sealed) -> Store6MutationsSkie.Skie.Store6Mutations.MutationPresence.__Sealed { + if let sealed = sealed as? Store6MutationsSkie.MutationPresenceAbsent { + return Store6MutationsSkie.Skie.Store6Mutations.MutationPresence.__Sealed.absent(sealed) + } else if let sealed = sealed as? Store6MutationsSkie.MutationPresencePresent { + return Store6MutationsSkie.Skie.Store6Mutations.MutationPresence.__Sealed.present(sealed) + } else { + fatalError("Unknown subtype \(sealed). This error should not happen under normal circumstances since SirClass: Store6MutationsSkie.MutationPresence is sealed.") + } +} + +@_disfavoredOverload +public func onEnum<__Sealed : Store6MutationsSkie.MutationPresence>(of sealed: __Sealed?) -> Store6MutationsSkie.Skie.Store6Mutations.MutationPresence.__Sealed? { + if let sealed { + return onEnum(of: sealed) as Store6MutationsSkie.Skie.Store6Mutations.MutationPresence.__Sealed + } else { + return nil + } +} + +// FILE: Store6Mutations/Store6Mutations.MutationPresenceState.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +@frozen +public enum MutationPresenceState : Swift.Hashable, Swift.CaseIterable, + Swift._ObjectiveCBridgeable { + + case present + case absent + + public var name: Swift.String { + return (self as _ObjectiveCType).name + } + + public var ordinal: Swift.Int32 { + return (self as _ObjectiveCType).ordinal + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6MutationsSkie.__MutationPresenceState, result: inout Store6MutationsSkie.MutationPresenceState?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6MutationsSkie.__MutationPresenceState, result: inout Store6MutationsSkie.MutationPresenceState?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6MutationsSkie.__MutationPresenceState?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6MutationsSkie.__MutationPresenceState { + switch self { + case .present: return Store6MutationsSkie.__MutationPresenceState.present as Store6MutationsSkie.__MutationPresenceState + case .absent: return Store6MutationsSkie.__MutationPresenceState.absent as Store6MutationsSkie.__MutationPresenceState + } + } + + private static func fromObjectiveC(_ source: Store6MutationsSkie.__MutationPresenceState?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6MutationsSkie.MutationPresenceState") + } + if source == Store6MutationsSkie.__MutationPresenceState.present as Store6MutationsSkie.__MutationPresenceState { + return .present + } else if source == Store6MutationsSkie.__MutationPresenceState.absent as Store6MutationsSkie.__MutationPresenceState { + return .absent + } else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6MutationsSkie.__MutationPresenceState") + } + } + + public typealias _ObjectiveCType = Store6MutationsSkie.__MutationPresenceState + +} + +extension Store6MutationsSkie.MutationPresenceState { + + public func toKotlinEnum() -> Store6MutationsSkie.__MutationPresenceState { + return _bridgeToObjectiveC() + } + +} + +extension Store6MutationsSkie.__MutationPresenceState { + + public func toSwiftEnum() -> Store6MutationsSkie.MutationPresenceState { + return Store6MutationsSkie.MutationPresenceState._unconditionallyBridgeFromObjectiveC(self) + } + +} + +// FILE: Store6Mutations/Store6Mutations.MutationServer.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6MutationsSkie.MutationServer { + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func push(request: Store6MutationsSkie.MutationPush) async throws -> Store6MutationsSkie.MutationAck { + return try await SwiftCoroutineDispatcher.dispatch { + Store6MutationsSkie.__SkieSuspendWrappersKt.Skie_Suspend__1__push(dispatchReceiver: self, request: request, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func retire(request: Store6MutationsSkie.MutationRetirement) async throws -> Store6MutationsSkie.MutationRetirementAck { + return try await SwiftCoroutineDispatcher.dispatch { + Store6MutationsSkie.__SkieSuspendWrappersKt.Skie_Suspend__2__retire(dispatchReceiver: self, request: request, suspendHandler: $0) + } + } + +} + +// FILE: Store6Mutations/Store6Mutations.MutationStore.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6MutationsSkie.Skie.Store6Mutations.MutationStore { + + public struct __Suspend { + + public let __kotlinObject: Store6MutationsSkie.MutationStore + + public init(_ __kotlinObject: Store6MutationsSkie.MutationStore) { + self.__kotlinObject = __kotlinObject + } + + } + +} + +public func skie(_ kotlinObject: Store6MutationsSkie.MutationStore) -> Store6MutationsSkie.Skie.Store6Mutations.MutationStore.__Suspend { + return Store6MutationsSkie.Skie.Store6Mutations.MutationStore.__Suspend(kotlinObject) +} + +extension Store6MutationsSkie.Skie.Store6Mutations.MutationStore.__Suspend { + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func deadLetters() async throws -> [Store6MutationsSkie.DeadLetter] { + return try await SwiftCoroutineDispatcher.dispatch { + Store6MutationsSkie.__SkieSuspendWrappersKt.Skie_Suspend__3__deadLetters(dispatchReceiver: __kotlinObject as! Store6MutationsSkie.MutationStore, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func drain(key: K) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6MutationsSkie.__SkieSuspendWrappersKt.Skie_Suspend__4__drain(dispatchReceiver: __kotlinObject as! Store6MutationsSkie.MutationStore, key: key as! Store6MutationsSkie.Store6_coreStoreKey, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func drain() async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6MutationsSkie.__SkieSuspendWrappersKt.Skie_Suspend__5__drain(dispatchReceiver: __kotlinObject as! Store6MutationsSkie.MutationStore, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func mutate( + key: K, + ref: Store6MutationsSkie.MutatorRef, + args: Any + ) async throws -> Swift.String { + return try await SwiftCoroutineDispatcher.dispatch { + Store6MutationsSkie.__SkieSuspendWrappersKt.Skie_Suspend__6__mutate(dispatchReceiver: __kotlinObject as! Store6MutationsSkie.MutationStore, key: key as! Store6MutationsSkie.Store6_coreStoreKey, ref: ref as! Store6MutationsSkie.MutatorRef, args: args, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func pending(key: K) async throws -> [Store6MutationsSkie.PendingIntent] { + return try await SwiftCoroutineDispatcher.dispatch { + Store6MutationsSkie.__SkieSuspendWrappersKt.Skie_Suspend__7__pending(dispatchReceiver: __kotlinObject as! Store6MutationsSkie.MutationStore, key: key as! Store6MutationsSkie.Store6_coreStoreKey, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func pendingWrites() async throws -> [Store6MutationsSkie.PendingIntent] { + return try await SwiftCoroutineDispatcher.dispatch { + Store6MutationsSkie.__SkieSuspendWrappersKt.Skie_Suspend__8__pendingWrites(dispatchReceiver: __kotlinObject as! Store6MutationsSkie.MutationStore, suspendHandler: $0) + } + } + +} + +// FILE: Store6Mutations/Store6Mutations.MutationStoreKt.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public func mutationStore( + registry: Store6MutationsSkie.MutatorRegistry, + server: Store6MutationsSkie.MutationServer, + keyResolver: Store6MutationsSkie.MutationKeyResolver, + valueCodecVersion: Swift.Int32, + valueCodec: Store6MutationsSkie.MutationCodec, + configure: @escaping (Store6MutationsSkie.MutationStoreBuilder) -> Swift.Void +) -> Store6MutationsSkie.MutationStore { + return Store6MutationsSkie.MutationStoreKt.mutationStore(registry: registry, server: server, keyResolver: keyResolver, valueCodecVersion: valueCodecVersion, valueCodec: valueCodec, configure: configure) +} + +// FILE: Store6Mutations/Store6Mutations.MutationTombstoneState.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +@frozen +public enum MutationTombstoneState : Swift.Hashable, Swift.CaseIterable, + Swift._ObjectiveCBridgeable { + + case pending + case active + case superseded + + public var name: Swift.String { + return (self as _ObjectiveCType).name + } + + public var ordinal: Swift.Int32 { + return (self as _ObjectiveCType).ordinal + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6MutationsSkie.__MutationTombstoneState, result: inout Store6MutationsSkie.MutationTombstoneState?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6MutationsSkie.__MutationTombstoneState, result: inout Store6MutationsSkie.MutationTombstoneState?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6MutationsSkie.__MutationTombstoneState?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6MutationsSkie.__MutationTombstoneState { + switch self { + case .pending: return Store6MutationsSkie.__MutationTombstoneState.pending as Store6MutationsSkie.__MutationTombstoneState + case .active: return Store6MutationsSkie.__MutationTombstoneState.active as Store6MutationsSkie.__MutationTombstoneState + case .superseded: return Store6MutationsSkie.__MutationTombstoneState.superseded as Store6MutationsSkie.__MutationTombstoneState + } + } + + private static func fromObjectiveC(_ source: Store6MutationsSkie.__MutationTombstoneState?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6MutationsSkie.MutationTombstoneState") + } + if source == Store6MutationsSkie.__MutationTombstoneState.pending as Store6MutationsSkie.__MutationTombstoneState { + return .pending + } else if source == Store6MutationsSkie.__MutationTombstoneState.active as Store6MutationsSkie.__MutationTombstoneState { + return .active + } else if source == Store6MutationsSkie.__MutationTombstoneState.superseded as Store6MutationsSkie.__MutationTombstoneState { + return .superseded + } else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6MutationsSkie.__MutationTombstoneState") + } + } + + public typealias _ObjectiveCType = Store6MutationsSkie.__MutationTombstoneState + +} + +extension Store6MutationsSkie.MutationTombstoneState { + + public func toKotlinEnum() -> Store6MutationsSkie.__MutationTombstoneState { + return _bridgeToObjectiveC() + } + +} + +extension Store6MutationsSkie.__MutationTombstoneState { + + public func toSwiftEnum() -> Store6MutationsSkie.MutationTombstoneState { + return Store6MutationsSkie.MutationTombstoneState._unconditionallyBridgeFromObjectiveC(self) + } + +} + +// FILE: Store6Mutations/Store6Mutations.MutatorRegistryKt.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public func mutatorRegistry(configure: @escaping (Store6MutationsSkie.MutatorRegistryBuilder) -> Swift.Void) -> Store6MutationsSkie.MutatorRegistry { + return Store6MutationsSkie.MutatorRegistryKt.mutatorRegistry(configure: configure) +} diff --git a/store6-mutations/build.gradle.kts b/store6-mutations/build.gradle.kts new file mode 100644 index 000000000..505266952 --- /dev/null +++ b/store6-mutations/build.gradle.kts @@ -0,0 +1,54 @@ +plugins { + id("org.mobilenativefoundation.store.store6.multiplatform") +} + +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + api(projects.store6Core) + } + } + + val commonTest by getting { + dependencies { + implementation(projects.store6Testing) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.turbine) + } + } + + val jvmTest by getting { + dependencies { + implementation(libs.lincheck) + } + } + } +} + +android { + namespace = "org.mobilenativefoundation.store6.mutations" +} + +// R1-13's JVM-only API-surface audit reads the committed BCV KLib dump. The lookup is explicit, +// never a working-directory assumption (021 plan T4.8). +tasks.withType().configureEach { + if (name == "jvmTest") { + systemProperty( + "store6.mutations.apiDumpDir", + layout.projectDirectory.dir("api").asFile.absolutePath, + ) + } +} + +tasks.named("jvmTest", org.jetbrains.kotlin.gradle.targets.jvm.tasks.KotlinJvmTest::class) { + // The Lincheck model-checking budget exceeds every default hosted CI lane: measured + // 2h40m-3h13m on hosted runners vs ~58-59m locally at the current suite, and once + // 9h23m locally at an earlier revision. The scheduled full-suite workflow passes + // -Pstore6.fullJvmSuite to run it; nothing else does. + if (!providers.gradleProperty("store6.fullJvmSuite").isPresent) { + filter { + excludeTestsMatching("org.mobilenativefoundation.store6.mutations.MutationJournalLincheckTest") + } + } +} diff --git a/store6-mutations/dokka/Module.md b/store6-mutations/dokka/Module.md new file mode 100644 index 000000000..67da8fba1 --- /dev/null +++ b/store6-mutations/dokka/Module.md @@ -0,0 +1,27 @@ +# Module store6-mutations + +Store 6 is in development and nothing is published yet. This reference describes the API as it +stands on `main`; install coordinates begin with 6.0.0-alpha01. + +## Experimental tier + +`store6-mutations` is an experimental, separate artifact. Every public symbol is marked +`@ExperimentalStoreApi`, its shapes may change in any release, and no mutations signature is +frozen. The first graduation review is at 6.1 with a target window of roughly 6.3, but graduation is +criteria-gated rather than date-driven. + +## Durable acknowledgement + +After the server returns an acknowledgement, Store records the receipt, any pending alias or +tombstone, and the `ACKED` phase in one journal transaction. Only after that commit does Store adopt +the result, apply effects, and finalize retirement. + +If the server accepts a push before the local acknowledgement transaction commits, replay can send +the same immutable generation with the same idempotency key again, so endpoints must be idempotent +or keyed by mutation identity. Once `ACKED` is durable, recovery may repeat adoption, effects, or +retirement but never calls `push` again for that generation. + +Return to [Docs home](https://store.mobilenativefoundation.org/docs), or use the +[Store 6 overview](https://store.mobilenativefoundation.org/docs/store6/overview) for guides and +examples. Read APIs are in the +[store6-core reference](https://store.mobilenativefoundation.org/reference/store6-core/index.html). diff --git a/store6-mutations/gradle.properties b/store6-mutations/gradle.properties new file mode 100644 index 000000000..cc1effec8 --- /dev/null +++ b/store6-mutations/gradle.properties @@ -0,0 +1,3 @@ +VERSION_NAME=6.0.0-SNAPSHOT +POM_NAME=store6-mutations +POM_ARTIFACT_ID=store6-mutations diff --git a/store6-mutations/src/androidMain/AndroidManifest.xml b/store6-mutations/src/androidMain/AndroidManifest.xml new file mode 100644 index 000000000..8072ee00d --- /dev/null +++ b/store6-mutations/src/androidMain/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationBookkeeper.kt b/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationBookkeeper.kt new file mode 100644 index 000000000..7a57331a7 --- /dev/null +++ b/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationBookkeeper.kt @@ -0,0 +1,200 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, +) + +package org.mobilenativefoundation.store6.mutations + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreMeta +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.core.seam.KeyStatus + +/** + * Mutations-owned in-memory [Bookkeeper] installed when the builder's bookkeeper door is unset. + * + * The factory forwards this exact instance to both the delegated core Store and the mutation + * engine so mutation metadata capture reads the same authority the Store updates; core's + * inaccessible internal default is never substituted. Certified against + * `BookkeeperContractKit`: + * + * - Identity derives exclusively from `(namespace.value, canonicalId())`; object identity and + * concrete key class never participate. + * - One store-local monotone sequence is shared by successes, per-key stale marks, and + * namespace/global watermarks. A key is durably stale exactly when + * `max(mark, namespace, global) > (success ?: 0)`; a failure-only record is therefore not + * durably stale, and a later success clears earlier staleness. + * - [recordSuccess] clears the prior failure timestamp and count. [forget], [forgetNamespace], + * and [forgetAll] remove key records — including per-key stale marks — but never reset + * namespace or global watermarks, and watermarks otherwise only advance. + * - Maintenance operations stage their full replacement state before publishing it, preserving + * the interface's exception atomicity for every `Throwable`, including cancellation: normal + * return means the full operation was applied, while throwing means it had no effect. + * + * Volatile like every in-memory default: durable staleness is simulated only while clients share + * this instance. The sequence supports `Long.MAX_VALUE` sequenced operations; a subsequent + * attempt fails before mutation instead of wrapping. + */ +internal class MutationBookkeeper : Bookkeeper { + private class Record( + val meta: StoreMeta?, + val lastSuccessSequence: Long?, + val lastFailureAtEpochMillis: Long?, + val consecutiveFailures: Int, + val staleSequence: Long?, + ) + + private val lock = Mutex() + private var records = HashMap() + private var namespaceStaleWatermarks = HashMap() + private var globalStaleWatermark = 0L + private var sequence = 0L + private val watermarkOnlyStatus = + KeyStatus( + meta = null, + lastSuccessSequence = null, + lastFailureAtEpochMillis = null, + consecutiveFailures = 0, + durablyStale = true, + ) + + override suspend fun recordSuccess( + key: StoreKey, + meta: StoreMeta, + ) { + val identity = key.identity() + lock.withLock { + val previous = records[identity] + val nextSequence = nextSequenceOrThrow() + records[identity] = + Record( + meta = meta, + lastSuccessSequence = nextSequence, + lastFailureAtEpochMillis = null, + consecutiveFailures = 0, + staleSequence = previous?.staleSequence, + ) + sequence = nextSequence + } + } + + override suspend fun recordFailure( + key: StoreKey, + atEpochMillis: Long, + ) { + val identity = key.identity() + lock.withLock { + val previous = records[identity] + records[identity] = + Record( + meta = previous?.meta, + lastSuccessSequence = previous?.lastSuccessSequence, + lastFailureAtEpochMillis = atEpochMillis, + consecutiveFailures = (previous?.consecutiveFailures ?: 0) + 1, + staleSequence = previous?.staleSequence, + ) + } + } + + override suspend fun status(key: StoreKey): KeyStatus? { + val identity = key.identity() + return lock.withLock { + val record = records[identity] + val coveringStaleSequence = + maxOf( + record?.staleSequence ?: 0L, + namespaceStaleWatermarks[identity.namespace] ?: 0L, + globalStaleWatermark, + ) + when { + record == null && coveringStaleSequence == 0L -> null + record == null -> watermarkOnlyStatus + else -> + KeyStatus( + meta = record.meta, + lastSuccessSequence = record.lastSuccessSequence, + lastFailureAtEpochMillis = record.lastFailureAtEpochMillis, + consecutiveFailures = record.consecutiveFailures, + durablyStale = + coveringStaleSequence > (record.lastSuccessSequence ?: 0L), + ) + } + } + } + + override suspend fun forget(key: StoreKey) { + val identity = key.identity() + lock.withLock { + records.remove(identity) + } + } + + override suspend fun markStale(key: StoreKey) { + val identity = key.identity() + lock.withLock { + val previous = records[identity] + val nextSequence = nextSequenceOrThrow() + val staged = copyRecords() + staged[identity] = + Record( + meta = previous?.meta, + lastSuccessSequence = previous?.lastSuccessSequence, + lastFailureAtEpochMillis = previous?.lastFailureAtEpochMillis, + consecutiveFailures = previous?.consecutiveFailures ?: 0, + staleSequence = nextSequence, + ) + records = staged + sequence = nextSequence + } + } + + override suspend fun advanceStaleWatermark(namespace: StoreNamespace) { + lock.withLock { + val nextSequence = nextSequenceOrThrow() + val staged = + HashMap(namespaceStaleWatermarks.size + 1).also { copy -> + copy.putAll(namespaceStaleWatermarks) + copy[namespace.value] = nextSequence + } + namespaceStaleWatermarks = staged + sequence = nextSequence + } + } + + override suspend fun advanceGlobalStaleWatermark() { + lock.withLock { + val nextSequence = nextSequenceOrThrow() + globalStaleWatermark = nextSequence + sequence = nextSequence + } + } + + override suspend fun forgetNamespace(namespace: StoreNamespace) { + lock.withLock { + val staged = HashMap(records.size) + records.forEach { (identity, record) -> + if (identity.namespace != namespace.value) { + staged[identity] = record + } + } + records = staged + } + } + + override suspend fun forgetAll() { + lock.withLock { + records = HashMap() + } + } + + private fun copyRecords(): HashMap = + HashMap(records.size + 1).also { copy -> copy.putAll(records) } + + private fun nextSequenceOrThrow(): Long { + check(sequence < Long.MAX_VALUE) { "Bookkeeper sequence exhausted" } + return sequence + 1L + } +} diff --git a/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt b/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt new file mode 100644 index 000000000..759104ac9 --- /dev/null +++ b/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt @@ -0,0 +1,4790 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, +) + +package org.mobilenativefoundation.store6.mutations + +import kotlin.random.Random +import kotlin.time.Clock +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreException +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreMeta +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.core.seam.Overlay +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import org.mobilenativefoundation.store6.core.seam.StoreResults +import org.mobilenativefoundation.store6.core.seam.StoreWriteHandle +import org.mobilenativefoundation.store6.core.seam.WallClock +import org.mobilenativefoundation.store6.mutations.storage.MutationAckRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationAttemptRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationClientRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationEffectDisposition +import org.mobilenativefoundation.store6.mutations.storage.MutationEffectKind +import org.mobilenativefoundation.store6.mutations.storage.MutationEffectRecord as StoredEffectRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionPhase as StoredExecutionPhase +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationFailureRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationIntentRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationKeyAliasRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationKeyTombstoneRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationTombstoneState + +private const val HYDRATION_FAILURE_DETAIL_VALUE_PRE_ACK: String = "value-codec-pre-ack" +private const val HYDRATION_FAILURE_DETAIL_VALUE_ACKED: String = "value-codec-acked" +private const val HYDRATION_FAILURE_DETAIL_MUTATOR_MISSING: String = "mutator-missing" +private const val HYDRATION_FAILURE_DETAIL_ARGS: String = "args-codec" + +/** The single attempt generation the codec-less path transmits; it never prepares a merge. */ +private const val IN_MEMORY_GENERATION: Int = 1 + +/** The internal default exponential-backoff constants; no public policy door exists. */ +private const val BACKOFF_BASE_MILLIS: Long = 1_000L +private const val BACKOFF_CAP_MILLIS: Long = 300_000L + +/** The trailing unchanged-conflict receipt bound. */ +private const val CONFLICT_UNCHANGED_BOUND: Int = 3 + +/** Stable machine detail for a resolver that returned null during global drain. */ +internal const val DRAIN_FAILURE_DETAIL_RESOLVER_NULL: String = "resolver-null" + +/** Stable machine detail for a resolver whose returned pair mismatched the request. */ +internal const val DRAIN_FAILURE_DETAIL_IDENTITY_MISMATCH: String = "resolver-identity-mismatch" + +/** Stable machine detail for a resolver that threw a non-cancellation failure. */ +internal const val DRAIN_FAILURE_DETAIL_RESOLVER_THROW: String = "resolver-throw" + +/** Stable machine detail for a mutator projection that threw before transport. */ +internal const val DRAIN_FAILURE_DETAIL_PROJECTION_THROW: String = "projection-throw" + +/** Stable machine detail for a keyed drain whose aliased terminal key failed to resolve. */ +internal const val DRAIN_FAILURE_DETAIL_KEYED_TERMINAL_UNRESOLVED: String = + "keyed-terminal-unresolved" + +/** + * Mutations-owned production clock used when the builder's `wallClock` door is unset. Core's + * own default (`SystemWallClock`) is internal to store6-core and cannot be threaded from this + * module; `kotlin.time.Clock` has been stable stdlib API since Kotlin 2.3. + */ +internal val MutationsSystemWallClock: WallClock = + object : WallClock { + override fun nowEpochMillis(): Long = Clock.System.now().toEpochMilliseconds() + } + +/** + * The outcome of one terminal-identity resolution attempt for a facade entry point. + * + * [Resolved] carries the terminal `K` and its identity. [Failed] carries the attempted terminal + * identity plus the sanctioned conversion message/cause pair: resolver null and identity + * mismatch have `cause == null`; a thrown non-cancellation failure retains its original cause + * only in this immediate carrier, never in durable state. + */ +internal sealed interface TerminalKeyResolution { + val identity: KeyIdentity + + class Resolved( + val key: K, + override val identity: KeyIdentity, + ) : TerminalKeyResolution + + class Failed( + override val identity: KeyIdentity, + val detail: String, + val message: String, + val cause: Throwable?, + ) : TerminalKeyResolution +} + +internal class FacadeSignalSnapshot( + val terminalIdentity: KeyIdentity, + val aliasRevisions: StateFlow, + val aliasRevisionBaseline: Long, + val resolutionPulses: StateFlow, + val resolutionPulseBaseline: Long, + val requiredProjectionStamp: Long, + val targetAppliedProjectionStamps: StateFlow, +) + +internal class MutationEngine( + private val registry: MutatorRegistry, + private val server: MutationServer, + private val journal: StorageBackedMutationJournal = InMemoryMutationJournal(), + // The exact Bookkeeper/SourceOfTruth instances installed in the delegated Store are + // retained here; ordered base capture reads [bookkeeper]. + internal val bookkeeper: Bookkeeper = MutationBookkeeper(), + internal val sourceOfTruth: SourceOfTruth = MutationSourceOfTruth(), + // Retained factory inputs. The resolver is the global-drain correctness path; the value + // codec/version isolate every push and adoption behind defensive blob copies; conflicts is + // stored for the conflict pipeline. Defaults exist only for direct engine construction in + // module tests. + internal val keyResolver: MutationKeyResolver = MutationKeyResolver { null }, + internal val valueCodecVersion: Int = 1, + internal val valueCodec: MutationCodec? = null, + internal val conflicts: MutationConflictRegistration? = null, + // The factory binds the delegated Store's LocalOnly read and clear door through these + // lambdas after the delegate exists; direct engine tests bind their own. + private val baseReader: suspend (K) -> V? = { null }, + private val freshnessBarrier: suspend (K) -> Unit = {}, + private val absentAdoption: suspend (K) -> Unit = {}, + // Stamps journal enqueue times and normalized failure times. The factory threads the + // builder-retained clock or the mutations-owned system default. + private val wallClock: WallClock = MutationsSystemWallClock, + // An internal-only deterministic seam. The default preserves production behavior; the + // focused backoff tests supply a seeded generator before eligibility policy is materialized. + private val backoffRandom: Random = Random.Default, + // The stable installation identity: one fixed string per engine, persisted on the durable + // client row and stamped into every push, attempt, and failure. + internal val clientId: String = "client-0", + private val namespaceInvalidation: suspend (StoreNamespace) -> Unit = {}, +) { + private val mutations = Mutex() + private val durableAckAdmission = Mutex() + private val hydration = Mutex() + private val globalDrainPass = Mutex() + private val retirementPass = Mutex() + private val aliasPublicationMutex = Mutex() + private val drainScheduler = IdentityDrainScheduler() + private val namespaceDrainScheduler = NamespaceDrainScheduler() + private val durableJournal = + journal.takeIf { candidate -> candidate.hydrateOnFirstUse && valueCodec != null } + private var hydrated = durableJournal == null + private var nextMutationSequence = 0L + private val signalSink = MutableSharedFlow(replay = 1) + private val poisonSink = + MutableSharedFlow( + replay = 16, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + private lateinit var handle: StoreWriteHandle + + // In-memory effect snapshots captured before first push; execution reads the durable rows. + private val effectSnapshots = AtomicMutableMap>() + private val durableEffectRows = AtomicMutableMap>() + private val hydratedTombstones = AtomicMutableList() + + // In-memory execution bookkeeping for truthful inspection. On a hydrated engine it is + // rebuilt from the durable records. + private val phases = AtomicMutableMap() + private val completedAttempts = AtomicMutableMap() + private val drainFailures = AtomicMutableList() + private val durableExecutions = AtomicMutableMap() + private val durableAttempts = AtomicMutableMap() + private val durableAcks = AtomicMutableMap() + private val acceptedIntentIdentities = AtomicMutableMap() + private val deadLettersByMutationId = AtomicMutableMap() + private val codecBlockedMutationIds = AtomicMutableSet() + private val preAckParkCandidates = AtomicMutableMap() + private val legacyPendingPresentAcks = + AtomicMutableMap>() + + // An ack target must remain stable even when the first legal receipt transaction rolls back. + // This process-local guard is read and written only while [mutations] is held; durable ACKED + // generations never consult it because they are never retransmitted. + private val ackEffectiveTargetsByIdempotencyKey = mutableMapOf() + + // Guarded by [mutations]. A just-committed enqueue cannot drain until its revision and + // MutationEnqueued publication complete in the same non-cancellable handoff. + private val pendingEnqueuePublications = mutableSetOf() + + // The live key map is CACHE ONLY. Global drain's correctness path is the durable journal + // identity plus the resolver; every cache hit is revalidated against the exact pair before + // reuse and a drifted entry is discarded. Facade terminal-alias resolution shares this + // cache under the same revalidation rule: path compression may be cached but never + // replaces durable edges. + private val liveKeys = MutableStateFlow>(emptyMap()) + + // The in-memory normalized alias table fronting the durable edges: same-namespace + // full-pair redirects with PENDING/ACTIVE states plus generation-idempotency receipts. + private val aliasRouter = + InMemoryAliasRouter( + storage = journal.storage, + runtimeState = journal.runtimeState, + ) + + // Lost-wakeup-free per-identity signals, all mutation-owned stateful monotonic counters: + // - [aliasRevisionSignals] advances only inside the NonCancellable retirement/activation + // handoff of a redirect's source identity; a live facade stream re-resolves on a strictly + // newer value and swaps delegates. + // - [resolutionPulseSignals] advances on every activation AND on every explicit non-stream + // facade/drain resolution attempt-or-success for an identity; a facade stream waiting + // after a resolver failure retries on a strictly newer value. A stream's own resolution + // attempt never advances either alias-routing signal. + // - [emittedProjectionStampSignals] advances for the terminal target before a durable alias + // retirement emits Overlay.changes. + // - [appliedProjectionStampSignals] catches up monotonically for the actual writer identity + // after successful projection. + private val aliasRevisionSignals = + MutableStateFlow>>(emptyMap()) + private val resolutionPulseSignals = + MutableStateFlow>>(emptyMap()) + private val emittedProjectionStampSignals = + MutableStateFlow>>(emptyMap()) + private val appliedProjectionStampSignals = + MutableStateFlow>>(emptyMap()) + + // The contiguous locally retired prefix, in-memory form, advertised on pushes. + private val retirementState = MutableStateFlow(RetirementState()) + private var retiredThroughSequence: Long + get() = retirementState.value.retiredThroughSequence + set(value) { + retirementState.update { current -> current.copy(retiredThroughSequence = value) } + } + private var serverConfirmedRetiredThroughSequence: Long + get() = retirementState.value.serverConfirmedRetiredThroughSequence + set(value) { + retirementState.update { current -> + current.copy(serverConfirmedRetiredThroughSequence = value) + } + } + private val retiredSequences = AtomicMutableSet() + + internal val changes: SharedFlow = signalSink.asSharedFlow() + internal val poisoned: SharedFlow = poisonSink.asSharedFlow() + + /** The advisory lifecycle bus republished by the facade. */ + internal val eventBus = MutationEventBus() + + /** + * Projects confirmed residence through the current pending intents. + * + * Store stamps a changed projection with `OVERLAY` origin, zero age, and no staleness. + * `OVERLAY` is therefore the pending-write affordance; staleness is not. The shared [changes] + * stream remains live and never completes or fails. + * + * The facade-internal applied-stamp write records the actual writer key and is a deliberate + * exception to [Overlay.apply]'s pure contract. It remains non-blocking and occurs once per + * successfully accepted trigger. + */ + internal val overlay: Overlay = + object : Overlay { + override fun apply( + key: K, + base: V?, + ): V? { + val writer = key.identity() + val terminal = terminalIdentityOf(writer) + val observedAtEntry = emittedProjectionStampSignal(terminal).value + val projected = projectAll(key, base) + appliedProjectionStampSignal(writer).update { current -> + maxOf(current, observedAtEntry) + } + return projected + } + + override val changes: Flow = this@MutationEngine.changes + } + + /** + * Rebuilds every process cache from one coherent durable snapshot. Codec work runs only after + * the storage transaction has returned; hydration never emits an overlay or alias revision. + */ + internal suspend fun ensureHydrated() { + val durable = durableJournal ?: return + hydration.withLock { + if (hydrated) return + val snapshot = durable.readDurableSnapshot() + val hydratedAliases = snapshot.aliases.toAliasEdgesBySource() + val activeTombstoneWatermarks = + snapshot.tombstones + .asSequence() + .filter { tombstone -> + tombstone.createdByClientId == clientId && + tombstone.state == MutationTombstoneState.ACTIVE + } + .groupBy { tombstone -> + terminalIdentity(tombstone.identity(), hydratedAliases) + } + .mapValues { (_, tombstones) -> + tombstones.maxOf { tombstone -> tombstone.createdBySequence } + } + val executionsBySequence = snapshot.executions.associateBy { it.clientSequence } + val attemptsByIdentity = + snapshot.attempts.associateBy { it.clientSequence to it.generation } + validateUniqueDurableNamespaceOwners(snapshot.executions, attemptsByIdentity) + hydratedTombstones.clear() + hydratedTombstones.addAll(snapshot.tombstones) + val failuresById = snapshot.failures.associateBy(MutationFailureRecord::failureId) + val effectsBySequence = snapshot.effects.groupBy { it.clientSequence } + val entriesByIdentity = linkedMapOf>>() + val existingCodecFailures = + snapshot.failures + .asSequence() + .filter { failure -> failure.kind == MutationFailureKind.CODEC } + .map { failure -> + Triple(failure.clientSequence, failure.generation, failure.detail) + } + .toMutableSet() + + phases.clear() + completedAttempts.clear() + durableExecutions.clear() + durableAttempts.clear() + durableAcks.clear() + acceptedIntentIdentities.clear() + deadLettersByMutationId.clear() + codecBlockedMutationIds.clear() + preAckParkCandidates.clear() + legacyPendingPresentAcks.clear() + effectSnapshots.clear() + durableEffectRows.clear() + drainFailures.clear() + retiredSequences.clear() + + val client = snapshot.client + if (client == null) { + durable.installHydratedState(emptyMap(), hydratedAliases) + aliasRouter.resetAfterHydration() + nextMutationSequence = 0L + retiredThroughSequence = 0L + serverConfirmedRetiredThroughSequence = 0L + hydrated = true + return + } + + for (failure in snapshot.failures) { + drainFailures += failure.toPublicFailure() + } + for (ack in snapshot.acks) { + val intent = snapshot.intents.firstOrNull { it.clientSequence == ack.clientSequence } + if (intent != null) durableAcks[intent.mutationId] = ack + } + + for (intent in snapshot.intents.sortedBy { it.clientSequence }) { + val execution = executionsBySequence[intent.clientSequence] ?: continue + val phase = execution.phase.toEnginePhase() + durableExecutions[intent.mutationId] = execution + phases[intent.mutationId] = phase + completedAttempts[intent.mutationId] = execution.attempt + if (execution.currentGeneration > 0) { + attemptsByIdentity[intent.clientSequence to execution.currentGeneration]?.let { + durableAttempts[intent.mutationId] = it + } + } + effectSnapshots[intent.mutationId] = + effectsBySequence[intent.clientSequence] + .orEmpty() + .sortedBy { it.effectIndex } + .map { effect -> + MutationEffectRecord( + kind = + when (effect.kind) { + MutationEffectKind.KEY -> MutationEffectRecordKind.KEY + MutationEffectKind.NAMESPACE -> MutationEffectRecordKind.NAMESPACE + }, + namespace = effect.namespace, + canonicalId = effect.canonicalId, + ) + } + durableEffectRows[intent.mutationId] = + effectsBySequence[intent.clientSequence] + .orEmpty() + .sortedBy { it.effectIndex } + + val attemptedIdentity = + durableAttempts[intent.mutationId]?.let { attempt -> + KeyIdentity(attempt.effectiveNamespace, attempt.effectiveCanonicalId) + } ?: KeyIdentity(intent.namespace, intent.canonicalId) + val effectiveIdentity = terminalIdentity(attemptedIdentity, hydratedAliases) + + if (phase == MutationExecutionPhase.PARKED) { + val failure = execution.activeFailureId?.let(failuresById::get) ?: continue + deadLettersByMutationId[intent.mutationId] = + DeadLetter( + namespace = effectiveIdentity.namespace, + canonicalId = effectiveIdentity.canonicalId, + mutationId = intent.mutationId, + mutatorId = intent.mutatorId, + generation = execution.currentGeneration, + attempts = execution.attempt, + failure = failure.toPublicFailure(), + parkedAtEpochMillis = failure.occurredAt, + ) + continue + } + if (phase == MutationExecutionPhase.RETIRED) { + if (intent.clientSequence > client.retiredThroughSequence) { + retiredSequences += intent.clientSequence + } + continue + } + if ( + intent.clientSequence <= + (activeTombstoneWatermarks[effectiveIdentity] ?: 0L) + ) { + continue + } + if ( + execution.currentGeneration == 0 && + execution.phase == StoredExecutionPhase.UNPREPARED + ) { + acceptedIntentIdentities[intent.mutationId] = + KeyIdentity(intent.namespace, intent.canonicalId) + } + + var blocked = false + val preAck = + phase == MutationExecutionPhase.UNPREPARED || + phase == MutationExecutionPhase.READY || + phase == MutationExecutionPhase.INFLIGHT || + phase == MutationExecutionPhase.REFRESH_REQUIRED + val parkOnDrain = phase.permitsPreAckParking(MutationFailureKind.CODEC) + val registration = registry.registrations[intent.mutatorId] + val args = + if (registration == null) { + if (preAck) { + classifyHydrationCodecFailure( + intent = intent, + generation = execution.currentGeneration, + detail = HYDRATION_FAILURE_DETAIL_MUTATOR_MISSING, + message = + "No mutator '${intent.mutatorId}' is registered for durable " + + "hydration.", + parkOnDrain = parkOnDrain, + existing = existingCodecFailures, + ) + blocked = true + } + UnavailableMutationArgs + } else { + try { + registration.decodeArgs(intent.mutatorVersion, intent.argsBlob) + } catch (failure: Throwable) { + if (failure is CancellationException) throw failure + if (preAck) { + classifyHydrationCodecFailure( + intent = intent, + generation = execution.currentGeneration, + detail = HYDRATION_FAILURE_DETAIL_ARGS, + message = + failure.message + ?: "Mutation args codec failed without a message.", + parkOnDrain = parkOnDrain, + existing = existingCodecFailures, + ) + blocked = true + } + UnavailableMutationArgs + } + } + + if (!blocked) { + try { + when (phase) { + MutationExecutionPhase.READY, + MutationExecutionPhase.INFLIGHT, + MutationExecutionPhase.REFRESH_REQUIRED, + -> { + val attempt = requireNotNull(durableAttempts[intent.mutationId]) + val codec = checkNotNull(valueCodec) + attempt.decodeBase(codec) + attempt.decodeMine(codec) + } + + MutationExecutionPhase.ACKED -> { + val ack = requireNotNull(durableAcks[intent.mutationId]) + if (ack.authoritativePresence == MutationPresenceState.PRESENT) { + checkNotNull(valueCodec).decodeCopied( + ack.valueCodecVersion, + checkNotNull(ack.authoritativeBlob), + ) + } + } + + MutationExecutionPhase.UNPREPARED, + MutationExecutionPhase.EFFECTS_PENDING, + MutationExecutionPhase.PARKED, + MutationExecutionPhase.RETIRED, + -> Unit + } + } catch (failure: Throwable) { + if (failure is CancellationException) throw failure + val postAck = phase == MutationExecutionPhase.ACKED + classifyHydrationCodecFailure( + intent = intent, + generation = execution.currentGeneration, + detail = + if (postAck) { + HYDRATION_FAILURE_DETAIL_VALUE_ACKED + } else { + HYDRATION_FAILURE_DETAIL_VALUE_PRE_ACK + }, + message = + failure.message + ?: "Mutation value codec failed without a message.", + parkOnDrain = parkOnDrain, + existing = existingCodecFailures, + ) + blocked = true + } + } + if (blocked) codecBlockedMutationIds += intent.mutationId + entriesByIdentity + .getOrPut(effectiveIdentity) { mutableListOf() } + .add( + JournalEntry( + mutationId = intent.mutationId, + mutatorId = intent.mutatorId, + args = args, + clientSequence = intent.clientSequence, + createdAtEpochMillis = intent.createdAt, + durableClientSequence = intent.clientSequence, + ), + ) + } + + durable.installHydratedState(entriesByIdentity, hydratedAliases) + aliasRouter.resetAfterHydration() + nextMutationSequence = client.lastAllocatedSequence + retiredThroughSequence = client.retiredThroughSequence + serverConfirmedRetiredThroughSequence = client.serverConfirmedRetiredThroughSequence + hydrated = true + } + } + + /** Rejects ambiguous restart authority before any hydrated runtime state is published. */ + private fun validateUniqueDurableNamespaceOwners( + executions: List, + attemptsByIdentity: Map, MutationAttemptRecord>, + ) { + val ownersByNamespace = linkedMapOf() + executions + .asSequence() + .filter { execution -> execution.ownsNamespaceAuthority() } + .sortedBy { execution -> execution.clientSequence } + .forEach { execution -> + val attempt = + requireNotNull( + attemptsByIdentity[ + execution.clientSequence to execution.currentGeneration + ], + ) { + "Namespace authority owner ${execution.clientSequence} has no current " + + "generation ${execution.currentGeneration} attempt." + } + val previous = + ownersByNamespace.put( + attempt.effectiveNamespace, + execution.clientSequence, + ) + check(previous == null) { + "Durable mutation journal has multiple authority owners for client " + + "'$clientId' namespace '${attempt.effectiveNamespace}': sequences " + + "$previous and ${execution.clientSequence}." + } + } + } + + private suspend fun classifyHydrationCodecFailure( + intent: MutationIntentRecord, + generation: Int, + detail: String, + message: String, + parkOnDrain: Boolean, + existing: MutableSet>, + ) { + codecBlockedMutationIds += intent.mutationId + if (parkOnDrain) { + preAckParkCandidates[intent.mutationId] = + PreAckParkCandidate( + kind = MutationFailureKind.CODEC, + detail = detail, + message = message, + ) + return + } + if (!existing.add(Triple(intent.clientSequence, generation, detail))) return + val durable = requireNotNull(durableJournal) + val occurredAt = wallClock.nowEpochMillis() + val normalized = + sanitizedMutationFailure( + kind = MutationFailureKind.CODEC, + detail = detail, + message = message, + occurredAtEpochMillis = occurredAt, + ) + durable.storage.transaction { transaction -> + transaction.appendFailure( + clientId = clientId, + clientSequence = intent.clientSequence, + generation = generation, + kind = normalized.kind, + detail = normalized.detail, + message = normalized.message, + occurredAt = occurredAt, + ) + } + drainFailures += normalized + } + + internal suspend fun mutate( + key: K, + ref: MutatorRef, + args: A, + ): String { + ensureHydrated() + require(ref.ownership === registry.ownership) { + "MutatorRef '${ref.id}' belongs to a different MutatorRegistry." + } + // Consumer resolution may suspend or re-enter this store, so it must remain outside the + // global enqueue/activation gate. It still gets exactly one resolution attempt. + val resolvedKey = requireTerminalKey(key) + val originalIdentity = key.identity() + val (mutationId, effectiveKey, enqueuedEvent) = + mutations.withLock { + // Revalidate only the immutable routing cache under the gate. If activation won + // after the one consumer resolution above, it published its exact target K under + // this same gate, so no second resolver attempt is needed. + val terminal = aliasRouter.terminalOf(originalIdentity) + val appendKey = + if (resolvedKey.identity() == terminal) { + resolvedKey + } else { + checkNotNull(cachedLiveKey(terminal)) { + "Alias activation published terminal identity $terminal without its " + + "exact canonical key." + } + } + val identity = appendKey.identity() + val sequence = nextMutationSequence + 1L + val nextId = "mutation-$sequence" + val createdAt = wallClock.nowEpochMillis() + journal.append( + identity, + JournalEntry( + mutationId = nextId, + mutatorId = ref.id, + args = args, + clientSequence = sequence, + createdAtEpochMillis = createdAt, + ), + ) + nextMutationSequence = sequence + cacheLiveKey(identity, appendKey) + if (durableJournal != null) acceptedIntentIdentities[nextId] = identity + pendingEnqueuePublications += nextId + phases[nextId] = MutationExecutionPhase.UNPREPARED + durableExecutions[nextId] = + MutationExecutionRecord( + clientId = clientId, + clientSequence = sequence, + phase = StoredExecutionPhase.UNPREPARED, + currentGeneration = 0, + attempt = 0, + lastAttemptAt = null, + activeFailureId = null, + retiredAt = null, + ) + Triple( + nextId, + appendKey, + MutationEnqueued( + mutationId = nextId, + identity = identity.toEventIdentity(), + occurredAtEpochMillis = createdAt, + clientSequence = sequence, + mutatorId = ref.id, + ), + ) + } + withContext(NonCancellable) { + signalSink.emit(effectiveKey) + eventBus.tryEmit(enqueuedEvent) + mutations.withLock { + check(pendingEnqueuePublications.remove(mutationId)) { + "Enqueue publication barrier was not retained for '$mutationId'." + } + } + } + return mutationId + } + + internal fun bind(handle: StoreWriteHandle) { + check(!this::handle.isInitialized) { + "Mutation engine is already bound." + } + this.handle = handle + } + + /** + * One idempotent keyed foreground pass: captures the unprojected confirmed base through the + * ordered `status -> LocalOnly` loop, then pushes the pending FIFO prefix once with no retry + * or backoff. A pre-ack codec/projection failure parks that head and continues its same-key + * suffix. A key without pending work is a no-op that reads nothing. The facade resolves the + * terminal alias identity before calling this; a mid-pass activation re-homes the pass to + * the canonical key and continues the sequence-merged prefix there. + */ + internal suspend fun drain( + key: K, + ensureOpen: () -> Unit = {}, + ) { + var retainedFailure: RetryablePostAckFailure? = null + try { + ensureHydrated() + drainIdentity( + key = key, + overrideBackoff = true, + ensureOpen = ensureOpen, + ) + } catch (failure: RetryablePostAckFailure) { + retainedFailure = failure + } + flushRetirementCheckpoint() + retainedFailure?.let { failure -> throw failure.exposed } + } + + /** + * One idempotent global foreground pass: enumerates durable identities from the journal, + * reconstructs each `K` through the retained resolver with exact-pair validation, and + * continues past identities that fail to resolve after parking exactly one owned durable + * pre-ack head (or retaining the precursor carrier when no such head exists). A sanctioned + * retryable post-ack failure retains its owner and does not block another namespace in this + * captured pass. Processing is deterministic by durable client sequence within an identity + * and enumerates a captured identity snapshot in first-enqueue order; no cross-key order is + * promised. + */ + internal suspend fun drain(ensureOpen: () -> Unit = {}) { + ensureHydrated() + globalDrainPass.withLock { + val sequenceBound = mutations.withLock { nextMutationSequence } + val identities = orderedGlobalDrainIdentities(journal.identities().toList()) + for (initialIdentity in identities) { + try { + drainGlobalIdentity(initialIdentity, sequenceBound, ensureOpen) + } catch (failure: RetryablePostAckFailure) { + if (!retainsRetryablePostAckOwner(failure)) { + throw failure.exposed + } + } + } + } + flushRetirementCheckpoint() + } + + /** + * Flushes the current contiguous local retirement prefix without holding a mutation, + * identity, namespace, runtime-cache, or retirement lock across consumer transport. + * Confirmation commits before a separate prune transaction. Non-cancellation failures are + * advisory client-scoped events only; they never create an intent failure row. + */ + private suspend fun flushRetirementCheckpoint() { + val durable = durableJournal ?: return + val beforeRequest = retirementState.value + val requestedThrough = beforeRequest.retiredThroughSequence + if (requestedThrough == 0L) return + val request = + MutationRetirement( + clientId = clientId, + retiredThroughSequence = requestedThrough, + ) + + val validatedConfirmation = + if (requestedThrough > beforeRequest.serverConfirmedRetiredThroughSequence) { + val ack = + try { + server.retire(request) + } catch (failure: CancellationException) { + throw failure + } catch (failure: Throwable) { + emitCheckpointFailure( + request = request, + kind = MutationFailureKind.TRANSPORT, + detail = "retire-failed", + failure = failure, + ) + return + } + try { + validateRetirementAck( + request = request, + ack = ack, + previousConfirmedThroughSequence = + beforeRequest.serverConfirmedRetiredThroughSequence, + ) + } catch (failure: CancellationException) { + throw failure + } catch (failure: Throwable) { + emitCheckpointFailure( + request = request, + kind = MutationFailureKind.PROTOCOL, + detail = "retire-ack-invalid", + failure = failure, + ) + return + } + } else { + null + } + + retirementPass.withLock { + val currentConfirmed = serverConfirmedRetiredThroughSequence + if (validatedConfirmation != null && validatedConfirmation > currentConfirmed) { + val persisted = + try { + durable.storage.transaction { transaction -> + transaction.confirmRetiredThrough( + clientId = clientId, + requestedThroughSequence = request.retiredThroughSequence, + serverConfirmedThroughSequence = validatedConfirmation, + ) + } + } catch (failure: CancellationException) { + throw failure + } catch (failure: Throwable) { + emitCheckpointFailure( + request = request, + kind = MutationFailureKind.PERSISTENCE, + detail = "retire-confirmation-failed", + failure = failure, + ) + return@withLock + } + serverConfirmedRetiredThroughSequence = + persisted.serverConfirmedRetiredThroughSequence + eventBus.tryEmit( + MutationCheckpointConfirmed( + occurredAtEpochMillis = wallClock.nowEpochMillis(), + clientId = clientId, + requestedThroughSequence = request.retiredThroughSequence, + confirmedThroughSequence = + persisted.serverConfirmedRetiredThroughSequence, + ), + ) + } + + val persistedConfirmed = serverConfirmedRetiredThroughSequence + if (persistedConfirmed == 0L) return@withLock + try { + durable.storage.transaction { transaction -> + transaction.prune( + clientId = clientId, + serverConfirmedRetiredThroughSequence = persistedConfirmed, + ) + } + } catch (failure: CancellationException) { + throw failure + } catch (failure: Throwable) { + emitCheckpointFailure( + request = request, + kind = MutationFailureKind.PERSISTENCE, + detail = "retire-prune-failed", + failure = failure, + ) + } + } + } + + /** Emits one sanitized ephemeral checkpoint failure; no intent or durable row is invented. */ + private fun emitCheckpointFailure( + request: MutationRetirement, + kind: MutationFailureKind, + detail: String, + failure: Throwable, + ) { + val occurredAt = wallClock.nowEpochMillis() + val normalized = + sanitizedMutationFailure( + kind = kind, + detail = detail, + message = failure.message ?: "Retirement checkpoint failed without a message.", + occurredAtEpochMillis = occurredAt, + ) + eventBus.tryEmit( + MutationCheckpointFailed( + occurredAtEpochMillis = occurredAt, + clientId = clientId, + requestedThroughSequence = request.retiredThroughSequence, + failure = normalized, + ), + ) + } + + /** Drops every cached resolution so the next global drain must reconstruct. */ + internal fun clearLiveKeyCache() { + liveKeys.value = emptyMap() + } + + /** + * The normalized in-memory drain failure carriers recorded so far: resolver `IDENTITY` + * failures and alias `PROTOCOL` failures. No original `Throwable` or `StoreError` is + * retained. + */ + internal fun drainFailuresForInspection(): List = drainFailures.toList() + + internal suspend fun pending(key: K): List { + ensureHydrated() + return pendingForIdentity(key.identity()) + } + + /** Snapshot terminal routing and rows together in durable client-sequence order. */ + internal fun pendingForIdentity(identity: KeyIdentity): List { + val tombstones = hydratedTombstones.toList() + val snapshot = journal.runtimeSnapshot() + val aliases = snapshot.aliases + val terminal = terminalIdentity(identity, aliases) + val entries = snapshot.entries[terminal].orEmpty() + return replayableEntries( + identity = terminal, + entries = entries, + aliases = aliases, + tombstones = tombstones, + ) + .sortedBy { entry -> entry.clientSequence } + .map { entry -> pendingRow(terminal, entry) } + } + + /** + * Snapshot rows for every durable identity in durable client-sequence order: the + * per-client sequence is the FIFO and watermark unit, so the global view sorts by it. + */ + internal suspend fun pendingWrites(): List { + ensureHydrated() + val tombstones = hydratedTombstones.toList() + val snapshot = journal.runtimeSnapshot() + val entriesByIdentity = snapshot.entries + val aliases = snapshot.aliases + return entriesByIdentity + .flatMap { (identity, entries) -> + replayableEntries(identity, entries, aliases, tombstones).map { entry -> + entry.clientSequence to pendingRow(identity, entry) + } + } + .sortedBy { (clientSequence, _) -> clientSequence } + .map { (_, row) -> row } + } + + /** + * Durably parked intents only, ordered by park time. The in-memory failure carriers are + * exposed by [drainFailuresForInspection]. + */ + internal suspend fun deadLetters(): List { + ensureHydrated() + return deadLettersByMutationId.values.sortedBy { it.parkedAtEpochMillis } + } + + internal fun projectAll( + key: K, + base: V?, + ): V? { + val identity = key.identity() + val tombstones = hydratedTombstones.toList() + val snapshot = journal.runtimeSnapshot() + val entries = snapshot.entries[identity].orEmpty() + val aliases = snapshot.aliases + var projected: MutationPresence = presenceOf(base) + for (entry in orderedPending(identity, entries, aliases, tombstones)) { + projected = project(projected, entry).value + } + return (projected as? MutationPresence.Present)?.value + } + + // ----------------------------------------------------------------------------------------- + // Canonical alias routing doors used by the facade. + // ----------------------------------------------------------------------------------------- + + /** The terminal identity for [identity] under the active alias edges. */ + internal fun terminalIdentityOf(identity: KeyIdentity): KeyIdentity = + aliasRouter.terminalOf(identity) + + /** + * The mutation-owned stateful alias revision for [identity]. It advances synchronously + * inside the NonCancellable retirement/activation handoff of a redirect whose source is + * [identity]; a live facade stream re-resolves on a strictly newer value. + */ + internal fun aliasRevision(identity: KeyIdentity): StateFlow = + aliasRevisionSignal(identity) + + /** + * The mutation-owned stateful resolution pulse for [identity]. It advances on alias + * activation and on every explicit non-stream facade/drain resolution attempt-or-success; + * a facade stream waiting after a resolver failure retries on a strictly newer value and a + * stream's own attempt never advances it. + */ + internal fun resolutionPulse(identity: KeyIdentity): StateFlow = + resolutionPulseSignal(identity) + + internal fun emittedProjectionStamp(identity: KeyIdentity): StateFlow = + emittedProjectionStampSignal(identity) + + internal fun appliedProjectionStamp(identity: KeyIdentity): StateFlow = + appliedProjectionStampSignal(identity) + + internal suspend fun facadeSignalSnapshot(identity: KeyIdentity): FacadeSignalSnapshot = + aliasPublicationMutex.withLock { + val terminal = terminalIdentityOf(identity) + val aliasRevisions = aliasRevisionSignal(terminal) + val resolutionPulses = resolutionPulseSignal(terminal) + FacadeSignalSnapshot( + terminalIdentity = terminal, + aliasRevisions = aliasRevisions, + aliasRevisionBaseline = aliasRevisions.value, + resolutionPulses = resolutionPulses, + resolutionPulseBaseline = resolutionPulses.value, + requiredProjectionStamp = emittedProjectionStampSignal(terminal).value, + targetAppliedProjectionStamps = appliedProjectionStampSignal(terminal), + ) + } + + /** Active subscriptions on [identity]'s resolution pulse; release-verification test door. */ + internal fun resolutionPulseSubscriptions(identity: KeyIdentity): Int = + resolutionPulseSignal(identity).subscriptionCount.value + + /** + * One terminal-identity resolution attempt for a facade entry point: the given + * key IS the terminal key when no active alias redirects its identity — the resolver is + * never consulted on that happy path. An aliased identity reuses the exact-pair-revalidated + * live cache or reconstructs the terminal `K` through the retained resolver with + * [requireResolvedKey]. This attempt never advances any signal; `CancellationException` is + * always rethrown. + */ + internal suspend fun resolveTerminalKey(key: K): TerminalKeyResolution { + ensureHydrated() + val terminal = aliasRouter.terminalOf(key.identity()) + if (terminal == key.identity()) { + return TerminalKeyResolution.Resolved(key, terminal) + } + cachedLiveKey(terminal)?.let { cached -> + return TerminalKeyResolution.Resolved(cached, terminal) + } + val requested = MutationKeyIdentity(terminal.namespace, terminal.canonicalId) + val resolved = + try { + keyResolver.resolve(requested) + } catch (failure: Throwable) { + if (failure is CancellationException) throw failure + return TerminalKeyResolution.Failed( + identity = terminal, + detail = DRAIN_FAILURE_DETAIL_RESOLVER_THROW, + message = + failure.message + ?: "MutationKeyResolver threw without a message for " + + "(${terminal.namespace}, ${terminal.canonicalId}).", + cause = failure, + ) + } + return try { + TerminalKeyResolution.Resolved( + key = + requireResolvedKey(requested, resolved).also { validated -> + cacheLiveKey(terminal, validated) + }, + identity = terminal, + ) + } catch (failure: IllegalStateException) { + TerminalKeyResolution.Failed( + identity = terminal, + detail = + if (resolved == null) { + DRAIN_FAILURE_DETAIL_RESOLVER_NULL + } else { + DRAIN_FAILURE_DETAIL_IDENTITY_MISMATCH + }, + message = failure.message.orEmpty(), + cause = null, + ) + } + } + + /** + * The suspending-facade resolution door: one attempt, then the sanctioned + * `StoreResults.exception(StoreResults.conversionError(message, cause), cause)` on failure. + * The attempt-or-success advances the identity's resolution pulse so live streams waiting on + * that identity wake and retry. + */ + internal suspend fun requireTerminalKey(key: K): K { + val resolution = resolveTerminalKey(key) + bumpResolutionPulse(resolution.identity) + return when (resolution) { + is TerminalKeyResolution.Resolved -> resolution.key + is TerminalKeyResolution.Failed -> + throw StoreResults.exception( + StoreResults.conversionError(resolution.message, resolution.cause), + resolution.cause, + ) + } + } + + /** + * The keyed-drain resolution door: a failed terminal resolution parks one owned durable + * pre-ack head and returns normally. A codec-less engine, a missing head, or a later-owned head + * preserves the normalized precursor carrier. The attempt-or-success advances the identity's + * resolution pulse exactly like every non-stream facade attempt. + */ + internal suspend fun drainKeyResolvedOrRecord( + key: K, + ensureOpen: () -> Unit = {}, + ) { + var retainedFailure: RetryablePostAckFailure? = null + try { + ensureHydrated() + withDurableNamespaceLease(key.identity()) { + drainKeyResolvedOrRecordUnderLease(key, ensureOpen) + } + } catch (failure: RetryablePostAckFailure) { + retainedFailure = failure + } + flushRetirementCheckpoint() + retainedFailure?.let { failure -> throw failure.exposed } + } + + private suspend fun drainKeyResolvedOrRecordUnderLease( + key: K, + ensureOpen: () -> Unit, + ) { + val sequenceBound = mutations.withLock { nextMutationSequence } + var lockIdentity = key.identity() + while (true) { + var activeTerminal: KeyIdentity? = null + var passRehome: DrainRehome? = null + var completed = false + drainScheduler.withIdentity(lockIdentity) { + ensureOpen() + val terminal = aliasRouter.terminalOf(lockIdentity) + if (terminal != lockIdentity) { + activeTerminal = terminal + return@withIdentity + } + + val resolution = resolveTerminalKey(key) + if (resolution.identity != lockIdentity) { + activeTerminal = resolution.identity + return@withIdentity + } + bumpResolutionPulse(resolution.identity) + when (resolution) { + is TerminalKeyResolution.Resolved -> { + passRehome = + drainIdentityLocked( + key = resolution.key, + lockKey = resolution.key, + sequenceBound = sequenceBound, + overrideBackoff = true, + initialEntry = null, + ) + } + is TerminalKeyResolution.Failed -> { + val head = + nextEligibleHead( + identity = resolution.identity, + sequenceBound = sequenceBound, + overrideBackoff = true, + ) + val entry = head?.entry + val phase = head?.phase + if ( + durableJournal != null && + entry != null && + (phase ?: MutationExecutionPhase.UNPREPARED) + .permitsPreAckParking(MutationFailureKind.IDENTITY) + ) { + parkDurablePreAck( + identity = resolution.identity, + entry = entry, + kind = MutationFailureKind.IDENTITY, + detail = resolution.detail, + message = resolution.message, + ) + } else { + // Keep the no-head/later-owned posture. Only an affected executable + // pre-ack row may create a durable terminal failure. + recordNormalizedFailure( + kind = MutationFailureKind.IDENTITY, + detail = DRAIN_FAILURE_DETAIL_KEYED_TERMINAL_UNRESOLVED, + message = resolution.message, + ) + } + } + } + completed = true + } + val terminal = activeTerminal + if (terminal != null) { + lockIdentity = terminal + continue + } + val rehome = passRehome + if (rehome != null) { + continueRehomedDrain(rehome, overrideBackoff = true, ensureOpen = ensureOpen) + } + if (completed) return + } + } + + private fun aliasRevisionSignal(identity: KeyIdentity): MutableStateFlow = + signalFor(aliasRevisionSignals, identity) + + private fun resolutionPulseSignal(identity: KeyIdentity): MutableStateFlow = + signalFor(resolutionPulseSignals, identity) + + private fun emittedProjectionStampSignal(identity: KeyIdentity): MutableStateFlow = + signalFor(emittedProjectionStampSignals, identity) + + private fun appliedProjectionStampSignal(identity: KeyIdentity): MutableStateFlow = + signalFor(appliedProjectionStampSignals, identity) + + private fun signalFor( + signals: MutableStateFlow>>, + identity: KeyIdentity, + ): MutableStateFlow { + signals.value[identity]?.let { return it } + val candidate = MutableStateFlow(0L) + signals.update { current -> + if (identity in current) current else current + (identity to candidate) + } + return checkNotNull(signals.value[identity]) + } + + private fun bumpResolutionPulse(identity: KeyIdentity) { + resolutionPulseSignal(identity).update { revision -> revision + 1L } + } + + private fun cachedLiveKey(identity: KeyIdentity): K? { + val cached = liveKeys.value[identity] ?: return null + if ( + cached.namespace.value == identity.namespace && + cached.canonicalId() == identity.canonicalId + ) { + return cached + } + liveKeys.update { current -> + if (current[identity] === cached) current - identity else current + } + return null + } + + private fun cacheLiveKey( + identity: KeyIdentity, + key: K, + ) { + check( + key.namespace.value == identity.namespace && + key.canonicalId() == identity.canonicalId, + ) { + "Live key (${key.namespace.value}, ${key.canonicalId()}) does not match cache " + + "identity (${identity.namespace}, ${identity.canonicalId})." + } + liveKeys.update { current -> current + (identity to key) } + } + + private fun orderedPending( + identity: KeyIdentity, + entries: List>, + aliases: Map, + tombstones: List, + ): List> = + replayableEntries(identity, entries, aliases, tombstones) + .sortedBy { entry -> entry.clientSequence } + + private fun replayableEntries( + identity: KeyIdentity, + entries: List>, + aliases: Map, + tombstones: List, + ): List> { + val terminal = terminalIdentity(identity, aliases) + val watermark = + tombstones + .filter { tombstone -> + tombstone.createdByClientId == clientId && + tombstone.state == MutationTombstoneState.ACTIVE && + terminalIdentity(tombstone.identity(), aliases) == terminal + }.maxOfOrNull { tombstone -> tombstone.createdBySequence } + ?: return entries + return entries.filter { entry -> entry.clientSequence > watermark } + } + + private fun pendingRow( + identity: KeyIdentity, + entry: JournalEntry, + ): PendingIntent = + PendingIntent( + namespace = identity.namespace, + canonicalId = identity.canonicalId, + mutationId = entry.mutationId, + mutatorId = entry.mutatorId, + state = publicStateOf(entry.mutationId), + attempt = completedAttempts[entry.mutationId] ?: 0, + createdAtEpochMillis = entry.createdAtEpochMillis, + ) + + private fun publicStateOf(mutationId: String): MutationPendingState = + (phases[mutationId] ?: MutationExecutionPhase.UNPREPARED).toPendingStateOrNull() + // A journalled row can never be PARKED or RETIRED; PENDING is the total mapping's + // only legal fallback for an unobserved phase. + ?: MutationPendingState.PENDING + + /** + * The exact-pair resolution for global drain. Cache hits are revalidated verbatim before + * reuse; non-cancellation failures return a structured normalized carrier so the caller can + * park an owned durable pre-ack head or preserve the in-memory precursor. The original + * throwable is never persisted. `CancellationException` is always rethrown. + */ + private suspend fun resolveForDrain(identity: KeyIdentity): TerminalKeyResolution { + cachedLiveKey(identity)?.let { cached -> + return TerminalKeyResolution.Resolved(cached, identity) + } + val requested = MutationKeyIdentity(identity.namespace, identity.canonicalId) + val resolved = + try { + keyResolver.resolve(requested) + } catch (failure: Throwable) { + if (failure is CancellationException) throw failure + return TerminalKeyResolution.Failed( + identity = identity, + detail = DRAIN_FAILURE_DETAIL_RESOLVER_THROW, + message = failure.message ?: "MutationKeyResolver threw without a message.", + cause = failure, + ) + } + return try { + TerminalKeyResolution.Resolved( + key = + requireResolvedKey(requested, resolved).also { validated -> + cacheLiveKey(identity, validated) + }, + identity = identity, + ) + } catch (failure: IllegalStateException) { + TerminalKeyResolution.Failed( + identity = identity, + detail = + if (resolved == null) { + DRAIN_FAILURE_DETAIL_RESOLVER_NULL + } else { + DRAIN_FAILURE_DETAIL_IDENTITY_MISMATCH + }, + message = failure.message.orEmpty(), + cause = null, + ) + } + } + + private fun recordNormalizedFailure( + kind: MutationFailureKind, + detail: String, + message: String, + ) { + drainFailures += + sanitizedMutationFailure( + kind = kind, + detail = detail, + message = message, + occurredAtEpochMillis = wallClock.nowEpochMillis(), + ) + } + + /** + * Captures C8's causal owner order for one global pass. Namespaces that already have an + * owner contribute only that owner's residence; their independent nonowners wait for the + * next trigger even when the owner retires during this pass. Other namespaces retain the + * journal's captured first-enqueue order. + */ + private fun orderedGlobalDrainIdentities( + captured: List, + ): List { + val owners = durableNamespaceOwners().values.sortedBy { owner -> owner.clientSequence } + if (owners.isEmpty()) return captured + val ownedNamespaces = owners.mapTo(mutableSetOf()) { owner -> owner.identity.namespace } + return (owners.map { owner -> owner.identity } + + captured.filter { identity -> identity.namespace !in ownedNamespaces }) + .distinct() + } + + /** + * Runs one durable namespace pass when its process lease is unowned or belongs to the same + * effective identity. Same-effective contenders reserve and wait; distinct contenders return + * without resolving consumer code or beginning transport. Durable ownership is rechecked + * after the lease is acquired. + */ + private suspend fun withDurableNamespaceLease( + requestedIdentity: KeyIdentity, + block: suspend () -> Unit, + ) { + val durable = durableJournal + if (durable == null) { + block() + return + } + namespaceDrainScheduler.tryWithNamespace( + requestedIdentity = requestedIdentity, + sameEffectiveIdentity = { leasedIdentity, contenderIdentity -> + val aliases = durable.runtimeState.aliasesSnapshot() + terminalIdentity(leasedIdentity, aliases) == + terminalIdentity(contenderIdentity, aliases) + }, + ) { + val owner = durableNamespaceOwners()[requestedIdentity.namespace] + if ( + owner != null && + owner.identity != aliasRouter.terminalOf(requestedIdentity) + ) { + return@tryWithNamespace + } + block() + } + } + + /** Derives the one owner per namespace exclusively from existing execution/attempt rows. */ + private fun durableNamespaceOwners(): Map { + if (durableJournal == null) return emptyMap() + val attemptsBySequence = + durableAttempts.values.associateBy { attempt -> attempt.clientSequence } + val owners = linkedMapOf() + durableExecutions.values + .asSequence() + .filter { execution -> execution.ownsNamespaceAuthority() } + .sortedBy { execution -> execution.clientSequence } + .forEach { execution -> + val attempt = + requireNotNull(attemptsBySequence[execution.clientSequence]) { + "Namespace authority owner ${execution.clientSequence} has no current " + + "attempt." + } + val identity = + aliasRouter.terminalOf( + KeyIdentity(attempt.effectiveNamespace, attempt.effectiveCanonicalId), + ) + val owner = + DurableNamespaceOwner( + clientSequence = execution.clientSequence, + identity = identity, + ) + val previous = owners.put(attempt.effectiveNamespace, owner) + check(previous == null) { + "Durable mutation journal has multiple authority owners for client " + + "'$clientId' namespace '${attempt.effectiveNamespace}': sequences " + + "${previous?.clientSequence} and ${execution.clientSequence}." + } + } + return owners + } + + /** + * The keyed foreground pass. The base is captured once through the ordered loop; after each + * in-pass adoption the acknowledged authoritative presence becomes the next entry's base and + * its metadata is re-read from the retained bookkeeper, where confirmation has already been + * recorded and therefore cannot lead the adopted value. + * + * Alias interaction: the pass drains the durable-client-sequence prefix that existed + * when it started, selecting the lowest pending sequence at the CURRENT effective identity + * each step. When a Present acknowledgement's redirect activates mid-pass, the pass re-homes + * to the canonical key and continues the sequence-merged siblings there. An acknowledgement + * that fails alias-protocol validation records one normalized `PROTOCOL` carrier, returns + * the intent to `READY` with a completed-attempt fact, performs no adoption, and halts this + * key's pass. + */ + private suspend fun drainGlobalIdentity( + initialIdentity: KeyIdentity, + sequenceBound: Long, + ensureOpen: () -> Unit, + ) { + withDurableNamespaceLease(initialIdentity) { + drainGlobalIdentityUnderLease(initialIdentity, sequenceBound, ensureOpen) + } + } + + private suspend fun drainGlobalIdentityUnderLease( + initialIdentity: KeyIdentity, + sequenceBound: Long, + ensureOpen: () -> Unit, + ) { + var lockIdentity = initialIdentity + while (true) { + var activeTerminal: KeyIdentity? = null + var passRehome: DrainRehome? = null + drainScheduler.withIdentity(lockIdentity) { + ensureOpen() + val terminal = aliasRouter.terminalOf(lockIdentity) + if (terminal != lockIdentity) { + activeTerminal = terminal + return@withIdentity + } + + // Eligibility is checked while holding the effective-identity lease and + // before resolver work. The selected entry carries this pass's single jitter + // draw into the resolved path. + val head = + nextEligibleHead(lockIdentity, sequenceBound, overrideBackoff = false) + ?: return@withIdentity + val entry = head.entry + when (val resolution = resolveForDrain(lockIdentity)) { + is TerminalKeyResolution.Resolved -> { + if (resolution.identity != lockIdentity) { + activeTerminal = resolution.identity + return@withIdentity + } + passRehome = + drainIdentityLocked( + key = resolution.key, + lockKey = resolution.key, + sequenceBound = sequenceBound, + overrideBackoff = false, + initialEntry = entry, + ) + } + is TerminalKeyResolution.Failed -> { + if (resolution.identity != lockIdentity) { + activeTerminal = resolution.identity + return@withIdentity + } + val phase = head.phase + if ( + durableJournal != null && + phase.permitsPreAckParking(MutationFailureKind.IDENTITY) + ) { + parkDurablePreAck( + identity = lockIdentity, + entry = entry, + kind = MutationFailureKind.IDENTITY, + detail = resolution.detail, + message = resolution.message, + ) + } else { + // Preserve the codec-less precursor and later-owned refresh/post-ack + // posture while continuing independent identities. + recordNormalizedFailure( + kind = MutationFailureKind.IDENTITY, + detail = resolution.detail, + message = resolution.message, + ) + } + } + } + } + val terminal = activeTerminal + if (terminal != null) { + lockIdentity = terminal + continue + } + val rehome = passRehome + if (rehome != null) { + continueRehomedDrain( + initial = rehome, + overrideBackoff = false, + ensureOpen = ensureOpen, + ) + } + return + } + } + + private suspend fun drainIdentity( + key: K, + overrideBackoff: Boolean, + sequenceBound: Long? = null, + initialEntry: JournalEntry? = null, + ensureOpen: () -> Unit = {}, + ) { + withDurableNamespaceLease(key.identity()) { + val capturedBound = sequenceBound ?: mutations.withLock { nextMutationSequence } + drainIdentityFromCursor( + processingKey = key, + lockKey = key, + sequenceBound = capturedBound, + overrideBackoff = overrideBackoff, + initialEntry = initialEntry, + ensureOpen = ensureOpen, + ) + } + } + + private suspend fun continueRehomedDrain( + initial: DrainRehome, + overrideBackoff: Boolean, + ensureOpen: () -> Unit, + ) { + val targetKey = + resolveExecutionIdentity( + acknowledged = initial.targetIdentity, + postAckEntry = initial.entry, + ) + drainIdentityFromCursor( + processingKey = initial.processingKey, + lockKey = targetKey, + sequenceBound = initial.sequenceBound, + overrideBackoff = overrideBackoff, + initialEntry = initial.entry, + ensureOpen = ensureOpen, + ) + } + + private suspend fun drainIdentityFromCursor( + processingKey: K, + lockKey: K, + sequenceBound: Long, + overrideBackoff: Boolean, + initialEntry: JournalEntry?, + ensureOpen: () -> Unit, + ) { + var currentProcessingKey = processingKey + var currentLockKey = lockKey + var currentEntry = initialEntry + while (true) { + var activeTargetIdentity: KeyIdentity? = null + var passRehome: DrainRehome? = null + val leaseIdentities = + setOf(currentProcessingKey.identity(), currentLockKey.identity()) + drainScheduler.withIdentities(leaseIdentities) { + ensureOpen() + val terminal = aliasRouter.terminalOf(currentLockKey.identity()) + if (terminal != currentLockKey.identity()) { + activeTargetIdentity = terminal + return@withIdentities + } + if (currentProcessingKey.identity() != currentLockKey.identity()) { + val processingTerminal = + aliasRouter.terminalOf(currentProcessingKey.identity()) + if (processingTerminal == currentLockKey.identity()) { + // Another continuation completed this source cursor while we waited for + // the ordered source+target lease. Continue the captured pass at the + // target suffix instead of replaying a retired source row. + currentProcessingKey = currentLockKey + currentEntry = null + } else if (processingTerminal != currentProcessingKey.identity()) { + activeTargetIdentity = processingTerminal + return@withIdentities + } + } + passRehome = + drainIdentityLocked( + key = currentProcessingKey, + lockKey = currentLockKey, + sequenceBound = sequenceBound, + overrideBackoff = overrideBackoff, + initialEntry = currentEntry, + ) + } + val targetIdentity = activeTargetIdentity + if (targetIdentity != null) { + // Resolution is consumer code and may suspend or re-enter drain. It must run + // only after the entire ordered lease set has been released. + val target = + resolveExecutionIdentity( + acknowledged = targetIdentity, + postAckEntry = currentEntry, + ) + if (currentProcessingKey.identity() == currentLockKey.identity()) { + currentProcessingKey = target + currentEntry = null + } + currentLockKey = target + continue + } + val rehome = passRehome ?: return + val targetKey = + resolveExecutionIdentity( + acknowledged = rehome.targetIdentity, + postAckEntry = rehome.entry, + ) + currentProcessingKey = rehome.processingKey + currentLockKey = targetKey + currentEntry = rehome.entry + } + } + + private suspend fun drainIdentityLocked( + key: K, + lockKey: K, + sequenceBound: Long, + overrideBackoff: Boolean, + initialEntry: JournalEntry?, + ): DrainRehome? = + if (durableJournal == null) { + drainIdentityLegacy(key, lockKey, sequenceBound, overrideBackoff, initialEntry) + } else { + drainIdentityDurable(key, lockKey, sequenceBound, overrideBackoff, initialEntry) + } + + /** The path taken by direct, codec-less engine constructions in module tests. */ + private suspend fun drainIdentityLegacy( + key: K, + lockKey: K, + sequenceBound: Long, + overrideBackoff: Boolean, + initialEntry: JournalEntry?, + ): DrainRehome? { + val lockIdentity = lockKey.identity() + var currentKey = key + var entry = + nextEligibleHead( + identity = currentKey.identity(), + sequenceBound = sequenceBound, + overrideBackoff = overrideBackoff, + initialEntry = initialEntry, + )?.entry ?: return null + var captured: CapturedBase? = null + while (true) { + val pendingAck = legacyPendingPresentAcks[entry.mutationId] + if (pendingAck != null) { + val terminalTarget = aliasRouter.terminalOf(pendingAck.targetKey.identity()) + if (terminalTarget != lockIdentity) { + return DrainRehome( + processingKey = pendingAck.sourceKey, + targetIdentity = terminalTarget, + entry = entry, + sequenceBound = sequenceBound, + ) + } + val normalizedPending = + if (lockKey === pendingAck.targetKey) { + pendingAck + } else { + LegacyPendingPresentAck( + sourceKey = pendingAck.sourceKey, + targetKey = lockKey, + authoritative = pendingAck.authoritative, + etag = pendingAck.etag, + ).also { normalized -> + legacyPendingPresentAcks[entry.mutationId] = normalized + } + } + val adoption = resumeLegacyPresentAck(entry, normalizedPending) + currentKey = adoption.effectiveKey + val next = + nextEligibleHead( + currentKey.identity(), + sequenceBound, + overrideBackoff, + )?.entry ?: return null + captured = + CapturedBase( + presence = MutationPresence.Present(adoption.adopted), + meta = snapshotMeta(bookkeeper.status(currentKey)?.meta), + ) + entry = next + continue + } + + val currentBase = captured ?: captureBase(currentKey).also { captured = it } + val outcome = project(currentBase.presence, entry) + if (!outcome.advanced) return null + val mine = outcome.value + if (!captureEffects(currentKey, entry)) return null + val push = + buildPush( + currentKey, + entry, + currentBase.presence, + mine, + currentBase.meta, + ) + phases[entry.mutationId] = MutationExecutionPhase.INFLIGHT + val ack = + try { + server.push(push) + } catch (failure: Throwable) { + // Transport cancellation is not failure: INFLIGHT stays intact and the next + // pass replays the same immutable generation. Only a non-cancellation + // failure records a completed-attempt fact and returns to READY. + if (failure is CancellationException) throw failure + completedAttempts[entry.mutationId] = + (completedAttempts[entry.mutationId] ?: 0) + 1 + phases[entry.mutationId] = MutationExecutionPhase.READY + return null + } + when (ack) { + is MutationPresentAck -> { + val staged = + stageLegacyPresentAck(currentKey, entry, push.idempotencyKey, ack) + ?: return null + val terminalTarget = aliasRouter.terminalOf(staged.targetKey.identity()) + if (terminalTarget != lockIdentity) { + return DrainRehome( + processingKey = staged.sourceKey, + targetIdentity = terminalTarget, + entry = entry, + sequenceBound = sequenceBound, + ) + } + val normalizedStaged = + if (lockKey === staged.targetKey) { + staged + } else { + LegacyPendingPresentAck( + sourceKey = staged.sourceKey, + targetKey = lockKey, + authoritative = staged.authoritative, + etag = staged.etag, + ).also { normalized -> + legacyPendingPresentAcks[entry.mutationId] = normalized + } + } + val adoption = resumeLegacyPresentAck(entry, normalizedStaged) + currentKey = adoption.effectiveKey + val next = + nextEligibleHead( + currentKey.identity(), + sequenceBound, + overrideBackoff, + )?.entry ?: return null + captured = + CapturedBase( + presence = MutationPresence.Present(adoption.adopted), + meta = snapshotMeta(bookkeeper.status(currentKey)?.meta), + ) + entry = next + continue + } + is MutationAbsentAck -> { + adoptAbsent(currentKey, entry) + captured = CapturedBase(MutationPresence.Absent, null) + entry = + nextEligibleHead( + currentKey.identity(), + sequenceBound, + overrideBackoff, + )?.entry ?: return null + continue + } + } + } + } + + /** + * Durable foreground pass. Phase dispatch happens before any consumer callback: READY and + * INFLIGHT replay their immutable attempt, ACKED resumes adoption, EFFECTS_PENDING resumes + * only retirement finalization, and only UNPREPARED may capture/project/prepare a generation. + */ + private suspend fun drainIdentityDurable( + key: K, + lockKey: K, + sequenceBound: Long, + overrideBackoff: Boolean, + initialEntry: JournalEntry?, + ): DrainRehome? { + val lockIdentity = lockKey.identity() + var currentKey = key + var entryHint = initialEntry + while (true) { + val head = + nextEligibleHead( + identity = currentKey.identity(), + sequenceBound = sequenceBound, + overrideBackoff = overrideBackoff, + initialEntry = entryHint, + ) ?: return null + val entry = head.entry + val phase = head.phase + entryHint = null + val parkCandidate = preAckParkCandidates[entry.mutationId] + if (parkCandidate != null) { + // The candidate's failure kind determines which pre-ack phases may park. + if (!phase.permitsPreAckParking(parkCandidate.kind)) return null + parkDurablePreAck( + identity = currentKey.identity(), + entry = entry, + kind = parkCandidate.kind, + detail = parkCandidate.detail, + message = parkCandidate.message, + ) + continue + } + if (entry.mutationId in codecBlockedMutationIds) { + if ( + phase != MutationExecutionPhase.ACKED || + !probeDurableAckCodec(entry) + ) { + return null + } + codecBlockedMutationIds.remove(entry.mutationId) + } + when (phase) { + MutationExecutionPhase.UNPREPARED -> { + when (prepareDurableAttempt(currentKey, entry)) { + DurablePreparationOutcome.BLOCKED -> return null + DurablePreparationOutcome.PARKED -> continue + DurablePreparationOutcome.PREPARED -> markDurableInflight(entry) + } + } + + MutationExecutionPhase.READY -> markDurableInflight(entry) + + MutationExecutionPhase.INFLIGHT -> Unit + + MutationExecutionPhase.REFRESH_REQUIRED -> { + val attempt = requireNotNull(durableAttempts[entry.mutationId]) + when (resumeDurableConflict(currentKey, entry, attempt)) { + DurableConflictOutcome.RETRY_PREPARED -> markDurableInflight(entry) + DurableConflictOutcome.PARKED, + DurableConflictOutcome.RETIRED, + -> continue + } + } + + MutationExecutionPhase.ACKED -> { + val effectiveIdentity = durableAckEffectiveIdentity(currentKey, entry) + if (effectiveIdentity != lockIdentity) { + return DrainRehome( + processingKey = currentKey, + targetIdentity = effectiveIdentity, + entry = entry, + sequenceBound = sequenceBound, + ) + } + val effectiveKey = + if (effectiveIdentity == currentKey.identity()) currentKey else lockKey + currentKey = resumeDurableAck(currentKey, effectiveKey, entry) ?: return null + continue + } + + MutationExecutionPhase.EFFECTS_PENDING -> { + val effectiveIdentity = durableAckEffectiveIdentity(currentKey, entry) + if (effectiveIdentity != lockIdentity) { + return DrainRehome( + processingKey = currentKey, + targetIdentity = effectiveIdentity, + entry = entry, + sequenceBound = sequenceBound, + ) + } + val effectiveKey = + if (effectiveIdentity == currentKey.identity()) currentKey else lockKey + currentKey = + resumeDurableEffectsPending(currentKey, effectiveKey, entry) ?: return null + continue + } + + MutationExecutionPhase.PARKED, + MutationExecutionPhase.RETIRED, + -> return null + } + + val attempt = requireNotNull(durableAttempts[entry.mutationId]) + val push = buildPushFromDurableAttempt(currentKey, entry, attempt) + val execution = requireNotNull(durableExecutions[entry.mutationId]) + // Completed durable attempts + 1; an INFLIGHT replay re-emits the same ordinal. + eventBus.tryEmit( + MutationAttempted( + mutationId = push.mutationId, + identity = push.identity, + occurredAtEpochMillis = wallClock.nowEpochMillis(), + generation = push.generation, + attempt = execution.attempt + 1, + ), + ) + val ack = + try { + server.push(push) + } catch (failure: Throwable) { + if (failure is CancellationException) throw failure + val conflict = (failure as? StoreException)?.error as? StoreError.Conflict + if (conflict == null) { + recordDurableTransportFailure(entry, failure) + return null + } + when (recordDurableConflict(entry, attempt, conflict)) { + DurableConflictReceiptOutcome.REFRESH_REQUIRED -> return null + DurableConflictReceiptOutcome.PARKED -> continue + } + } + when (ack) { + is MutationPresentAck -> { + when (recordDurableAckReceipt(entry, attempt, ack)) { + DurableAckReceiptOutcome.ACKED -> Unit + DurableAckReceiptOutcome.PARKED -> continue + DurableAckReceiptOutcome.HALTED -> return null + } + } + is MutationAbsentAck -> { + when (recordDurableAckReceipt(entry, attempt, ack)) { + DurableAckReceiptOutcome.ACKED -> Unit + DurableAckReceiptOutcome.PARKED -> continue + DurableAckReceiptOutcome.HALTED -> return null + } + } + } + // ACK persistence is the handoff point. Dispatching the persisted phase on the next + // loop iteration either adopts under this lease or requests a canonical re-home. + entryHint = entry + } + } + + private suspend fun prepareDurableAttempt( + key: K, + entry: JournalEntry, + ): DurablePreparationOutcome { + val durable = requireNotNull(durableJournal) + val captured = captureBase(key) + val projected = project(captured.presence, entry) + projected.failure?.let { failure -> + parkDurablePreAck( + identity = key.identity(), + entry = entry, + kind = MutationFailureKind.PROJECTION, + detail = DRAIN_FAILURE_DETAIL_PROJECTION_THROW, + message = failure.message ?: "Mutation projection failed without a message.", + ) + return DurablePreparationOutcome.PARKED + } + if (!projected.advanced) return DurablePreparationOutcome.BLOCKED + val preconditionMeta = + when ( + val selection = + selectPreconditionMeta( + key = key, + entry = entry, + generation = 1, + base = captured.presence, + mine = projected.value, + capturedMeta = captured.meta, + ) + ) { + is PreconditionSelection.Selected -> selection.meta + is PreconditionSelection.Failed -> { + parkDurableConflictFailure( + identity = key.identity(), + entry = entry, + detail = "selector-failed", + message = + selection.failure.message + ?: "Mutation precondition selector failed without a message.", + ) + return DurablePreparationOutcome.PARKED + } + } + val effects = evaluateEffects(key, entry) ?: return DurablePreparationOutcome.BLOCKED + val preparedAt = wallClock.nowEpochMillis() + val attempt = + buildDurableAttempt( + key = key, + entry = entry, + generation = 1, + base = captured.presence, + mine = projected.value, + preconditionMeta = preconditionMeta, + preparedAt = preparedAt, + ) + val storedEffects = + effects.mapIndexed { index, effect -> + StoredEffectRecord( + clientId = clientId, + clientSequence = entry.durableClientSequence, + effectIndex = index, + kind = + when (effect.kind) { + MutationEffectRecordKind.KEY -> MutationEffectKind.KEY + MutationEffectRecordKind.NAMESPACE -> MutationEffectKind.NAMESPACE + }, + namespace = effect.namespace, + canonicalId = effect.canonicalId, + createdAt = preparedAt, + disposition = MutationEffectDisposition.PENDING, + completedAt = null, + ) + } + val previous = requireNotNull(durableExecutions[entry.mutationId]) + val ready = + previous.copyExecution( + phase = StoredExecutionPhase.READY, + currentGeneration = 1, + attempt = 0, + lastAttemptAt = null, + ) + durable.storage.transaction { transaction -> + transaction.insertAttempt(attempt) + storedEffects.forEach(transaction::insertEffect) + transaction.advanceExecution(ready) + } + effectSnapshots[entry.mutationId] = effects + durableEffectRows[entry.mutationId] = storedEffects + durableAttempts[entry.mutationId] = attempt + acceptedIntentIdentities.remove(entry.mutationId) + durableExecutions[entry.mutationId] = ready + phases[entry.mutationId] = MutationExecutionPhase.READY + completedAttempts[entry.mutationId] = 0 + return DurablePreparationOutcome.PREPARED + } + + /** Selects and freezes metadata once for a newly prepared semantic generation. */ + private fun selectPreconditionMeta( + key: K, + entry: JournalEntry, + generation: Int, + base: MutationPresence, + mine: MutationPresence, + capturedMeta: StoreMeta?, + ): PreconditionSelection { + val selector = conflicts?.precondition + ?: return PreconditionSelection.Selected(snapshotMeta(capturedMeta)) + val candidate = + MutationPreconditionCandidate( + identity = MutationKeyIdentity(key.namespace.value, key.canonicalId()), + key = key, + mutationId = entry.mutationId, + generation = generation, + base = copiedPresence(base), + mine = copiedPresence(mine), + capturedMeta = snapshotMeta(capturedMeta), + ) + return try { + PreconditionSelection.Selected(snapshotMeta(selector(candidate))) + } catch (failure: Throwable) { + if (failure is CancellationException) throw failure + PreconditionSelection.Failed(failure) + } + } + + /** Builds one immutable attempt row; all consumer-owned values and metadata are copied. */ + private fun buildDurableAttempt( + key: K, + entry: JournalEntry, + generation: Int, + base: MutationPresence, + mine: MutationPresence, + preconditionMeta: StoreMeta?, + preparedAt: Long, + ): MutationAttemptRecord { + val codec = checkNotNull(valueCodec) + return MutationAttemptRecord( + clientId = clientId, + clientSequence = entry.durableClientSequence, + generation = generation, + effectiveNamespace = key.namespace.value, + effectiveCanonicalId = key.canonicalId(), + valueCodecVersion = valueCodecVersion, + basePresence = base.toPresenceState(), + baseBlob = base.encodePresentOrNull(codec), + minePresence = mine.toPresenceState(), + mineBlob = mine.encodePresentOrNull(codec), + preconditionMetaPresent = preconditionMeta != null, + preconditionWrittenAt = preconditionMeta?.writtenAtEpochMillis, + preconditionEtag = preconditionMeta?.etag, + advertisedRetiredThroughSequence = retiredThroughSequence, + generationIdempotencyKey = + "$clientId:${entry.durableClientSequence}:g$generation", + preparedAt = preparedAt, + conflictMetaPresent = null, + conflictWrittenAt = null, + conflictEtag = null, + conflictReceivedAt = null, + ) + } + + private suspend fun markDurableInflight(entry: JournalEntry) { + val durable = requireNotNull(durableJournal) + val previous = requireNotNull(durableExecutions[entry.mutationId]) + val inflight = previous.copyExecution(phase = StoredExecutionPhase.INFLIGHT) + durable.storage.transaction { it.advanceExecution(inflight) } + durableExecutions[entry.mutationId] = inflight + phases[entry.mutationId] = MutationExecutionPhase.INFLIGHT + } + + private suspend fun recordDurableTransportFailure( + entry: JournalEntry, + failure: Throwable, + ) { + val durable = requireNotNull(durableJournal) + val previous = requireNotNull(durableExecutions[entry.mutationId]) + val occurredAt = wallClock.nowEpochMillis() + val normalized = + sanitizedMutationFailure( + kind = MutationFailureKind.TRANSPORT, + detail = "push-failed", + message = failure.message ?: "Mutation transport failed without a message.", + occurredAtEpochMillis = occurredAt, + ) + val ready = + previous.copyExecution( + phase = StoredExecutionPhase.READY, + attempt = previous.attempt + 1, + lastAttemptAt = occurredAt, + ) + durable.storage.transaction { transaction -> + transaction.appendFailure( + clientId = clientId, + clientSequence = entry.durableClientSequence, + generation = previous.currentGeneration, + kind = normalized.kind, + detail = normalized.detail, + message = normalized.message, + occurredAt = occurredAt, + ) + transaction.advanceExecution(ready) + } + drainFailures += normalized + durableExecutions[entry.mutationId] = ready + phases[entry.mutationId] = MutationExecutionPhase.READY + completedAttempts[entry.mutationId] = ready.attempt + val attempt = requireNotNull(durableAttempts[entry.mutationId]) + eventBus.tryEmit( + MutationFailed( + mutationId = entry.mutationId, + identity = attempt.toEventIdentity(), + occurredAtEpochMillis = occurredAt, + generation = ready.currentGeneration, + state = MutationPendingState.PENDING, + failure = normalized, + ), + ) + } + + /** + * Records one completed server conflict. Ordinary receipts advance to REFRESH_REQUIRED with no + * failure row. The trailing unchanged run is derived from immutable attempts after this + * receipt is visible in the same transaction, and parks atomically on the third unchanged + * pair. + */ + private suspend fun recordDurableConflict( + entry: JournalEntry, + attempt: MutationAttemptRecord, + conflict: StoreError.Conflict, + ): DurableConflictReceiptOutcome = + withContext(NonCancellable) { + val durable = requireNotNull(durableJournal) + val previous = requireNotNull(durableExecutions[entry.mutationId]) + require(previous.phase == StoredExecutionPhase.INFLIGHT) { + "A conflict receipt requires INFLIGHT, but was ${previous.phase}." + } + require(previous.currentGeneration == attempt.generation) { + "A conflict receipt must target the current immutable generation." + } + + val serverMeta = conflict.serverMeta + val conflictMetaPresent = serverMeta != null + val conflictWrittenAt = serverMeta?.writtenAtEpochMillis + val conflictEtag = serverMeta?.etag + val receivedAt = wallClock.nowEpochMillis() + val receivedAttempt = + attempt.withConflictReceipt( + conflictMetaPresent = conflictMetaPresent, + conflictWrittenAt = conflictWrittenAt, + conflictEtag = conflictEtag, + conflictReceivedAt = receivedAt, + ) + val normalizedBoundFailure = + sanitizedMutationFailure( + kind = MutationFailureKind.CONFLICT, + detail = "conflict-unchanged-bound", + message = conflict.message, + occurredAtEpochMillis = receivedAt, + ) + + val commit = + durable.storage.transaction { transaction -> + transaction.recordConflictReceipt(receivedAttempt) + val trailingUnchanged = + transaction + .attempts(clientId) + .filter { candidate -> + candidate.clientSequence == entry.durableClientSequence && + candidate.generation <= receivedAttempt.generation + } + .asReversed() + .takeWhile { candidate -> + candidate.conflictMetaPresent != null && + candidate.conflictEtag == receivedAttempt.conflictEtag && + candidate.conflictWrittenAt == receivedAttempt.conflictWrittenAt + }.size + if (trailingUnchanged >= CONFLICT_UNCHANGED_BOUND) { + val failure = + transaction.appendFailure( + clientId = clientId, + clientSequence = entry.durableClientSequence, + generation = previous.currentGeneration, + kind = normalizedBoundFailure.kind, + detail = normalizedBoundFailure.detail, + message = normalizedBoundFailure.message, + occurredAt = receivedAt, + ) + val parked = + previous.copyExecution( + phase = StoredExecutionPhase.PARKED, + attempt = previous.attempt + 1, + lastAttemptAt = receivedAt, + activeFailureId = failure.failureId, + ) + transaction.advanceExecution(parked) + DurableConflictCommit( + attempt = receivedAttempt, + execution = parked, + failure = failure, + ) + } else { + val refreshRequired = + previous.copyExecution( + phase = StoredExecutionPhase.REFRESH_REQUIRED, + attempt = previous.attempt + 1, + lastAttemptAt = receivedAt, + ) + transaction.advanceExecution(refreshRequired) + DurableConflictCommit( + attempt = receivedAttempt, + execution = refreshRequired, + failure = null, + ) + } + } + + durableAttempts[entry.mutationId] = commit.attempt + val conflictEvent = + MutationConflictObserved( + mutationId = entry.mutationId, + identity = commit.attempt.toEventIdentity(), + occurredAtEpochMillis = checkNotNull(commit.attempt.conflictReceivedAt), + generation = commit.attempt.generation, + serverMeta = commit.attempt.conflictMetaOrNull(), + ) + val parkedFailure = commit.failure + if (parkedFailure == null) { + durableExecutions[entry.mutationId] = commit.execution + phases[entry.mutationId] = MutationExecutionPhase.REFRESH_REQUIRED + completedAttempts[entry.mutationId] = commit.execution.attempt + eventBus.tryEmit(conflictEvent) + DurableConflictReceiptOutcome.REFRESH_REQUIRED + } else { + eventBus.tryEmit(conflictEvent) + publishDurablePark( + identity = KeyIdentity(attempt.effectiveNamespace, attempt.effectiveCanonicalId), + entry = entry, + commit = DurableParkCommit(parkedFailure, commit.execution), + ) + DurableConflictReceiptOutcome.PARKED + } + } + + /** + * A conflict MustBeFresh read is a completion barrier only. `theirs` is recaptured afterward + * by the same `status -> LocalOnly` algorithm. StoreError.Missing means authoritative Absent; + * other refresh failures preserve the generation and backoff state. + */ + private suspend fun resumeDurableConflict( + key: K, + entry: JournalEntry, + attempt: MutationAttemptRecord, + ): DurableConflictOutcome { + freshnessBarrier(key) + val recaptured = captureBase(key) + val theirs = + CapturedBase( + presence = copiedPresence(recaptured.presence), + meta = snapshotMeta(recaptured.meta), + ) + val codec = checkNotNull(valueCodec) + val base = attempt.decodeBase(codec) + val mine = attempt.decodeMine(codec) + val merge = conflicts?.merge + val resolution: MutationConflictResolution = + if (merge == null) { + MutationConflictResolution.ServerWins + } else { + try { + merge(base, mine, copiedPresence(theirs.presence)) + } catch (failure: Throwable) { + if (failure is CancellationException) throw failure + parkDurableConflictFailure( + identity = KeyIdentity(attempt.effectiveNamespace, attempt.effectiveCanonicalId), + entry = entry, + detail = "merge-failed", + message = failure.message ?: "Mutation conflict merge failed without a message.", + ) + return DurableConflictOutcome.PARKED + } + } + return when (resolution) { + is MutationConflictResolution.Retry -> + when ( + prepareDurableRetryAttempt( + key = key, + entry = entry, + previousAttempt = attempt, + theirs = theirs, + mine = copiedPresence(resolution.value), + ) + ) { + DurablePreparationOutcome.PREPARED -> DurableConflictOutcome.RETRY_PREPARED + DurablePreparationOutcome.PARKED -> DurableConflictOutcome.PARKED + DurablePreparationOutcome.BLOCKED -> error("A conflict retry cannot be blocked.") + } + + MutationConflictResolution.ServerWins -> { + retireDurableServerWins( + identity = KeyIdentity(attempt.effectiveNamespace, attempt.effectiveCanonicalId), + entry = entry, + ) + DurableConflictOutcome.RETIRED + } + } + } + + /** Persists g+1 and READY atomically before the caller may mark it INFLIGHT or send it. */ + private suspend fun prepareDurableRetryAttempt( + key: K, + entry: JournalEntry, + previousAttempt: MutationAttemptRecord, + theirs: CapturedBase, + mine: MutationPresence, + ): DurablePreparationOutcome { + require( + key.namespace.value == previousAttempt.effectiveNamespace && + key.canonicalId() == previousAttempt.effectiveCanonicalId, + ) { "A conflict retry cannot change the durable effective identity." } + val generation = previousAttempt.generation + 1 + val preconditionMeta = + when ( + val selection = + selectPreconditionMeta( + key = key, + entry = entry, + generation = generation, + base = theirs.presence, + mine = mine, + capturedMeta = theirs.meta, + ) + ) { + is PreconditionSelection.Selected -> selection.meta + is PreconditionSelection.Failed -> { + parkDurableConflictFailure( + identity = + KeyIdentity( + previousAttempt.effectiveNamespace, + previousAttempt.effectiveCanonicalId, + ), + entry = entry, + detail = "selector-failed", + message = + selection.failure.message + ?: "Mutation precondition selector failed without a message.", + ) + return DurablePreparationOutcome.PARKED + } + } + val preparedAt = wallClock.nowEpochMillis() + val nextAttempt = + buildDurableAttempt( + key = key, + entry = entry, + generation = generation, + base = theirs.presence, + mine = mine, + preconditionMeta = preconditionMeta, + preparedAt = preparedAt, + ) + val durable = requireNotNull(durableJournal) + val previousExecution = requireNotNull(durableExecutions[entry.mutationId]) + require( + previousExecution.phase == StoredExecutionPhase.REFRESH_REQUIRED && + previousExecution.currentGeneration == previousAttempt.generation, + ) { "A conflict retry requires the current REFRESH_REQUIRED generation." } + val ready = + previousExecution.copyExecution( + phase = StoredExecutionPhase.READY, + currentGeneration = generation, + attempt = 0, + lastAttemptAt = null, + ) + durable.storage.transaction { transaction -> + transaction.insertAttempt(nextAttempt) + transaction.advanceExecution(ready) + } + durableAttempts[entry.mutationId] = nextAttempt + durableExecutions[entry.mutationId] = ready + phases[entry.mutationId] = MutationExecutionPhase.READY + completedAttempts[entry.mutationId] = 0 + return DurablePreparationOutcome.PREPARED + } + + /** + * Rows 4a/4b: atomically records one normalized pre-ack failure and makes it the terminal + * PARKED reason, then removes the row from executable projection and publishes that rebase as + * one cancellation-safe accepted-state handoff. + */ + private suspend fun parkDurablePreAck( + identity: KeyIdentity, + entry: JournalEntry, + kind: MutationFailureKind, + detail: String, + message: String, + ) { + val previous = requireNotNull(durableExecutions[entry.mutationId]) + require(previous.phase.toEnginePhase().permitsPreAckParking(kind)) { + "Pre-ack failure $kind may not park ${entry.mutationId} from ${previous.phase}." + } + if (previous.phase == StoredExecutionPhase.INFLIGHT) { + require(kind == MutationFailureKind.IDENTITY || kind == MutationFailureKind.CODEC) { + "An abandoned INFLIGHT replay may preserve attempt facts only for IDENTITY/CODEC." + } + } + parkDurableFailure(identity, entry, previous, kind, detail, message) + } + + /** Selector and merge failures retain their conflict-owned phase guard. */ + private suspend fun parkDurableConflictFailure( + identity: KeyIdentity, + entry: JournalEntry, + detail: String, + message: String, + ) { + val previous = requireNotNull(durableExecutions[entry.mutationId]) + require( + previous.phase == StoredExecutionPhase.UNPREPARED || + previous.phase == StoredExecutionPhase.REFRESH_REQUIRED, + ) { + "A selector/merge conflict may park only from UNPREPARED or REFRESH_REQUIRED; " + + "${entry.mutationId} was ${previous.phase}." + } + parkDurableFailure( + identity = identity, + entry = entry, + previous = previous, + kind = MutationFailureKind.CONFLICT, + detail = detail, + message = message, + ) + } + + private suspend fun parkDurableFailure( + identity: KeyIdentity, + entry: JournalEntry, + previous: MutationExecutionRecord, + kind: MutationFailureKind, + detail: String, + message: String, + ) { + val durable = requireNotNull(durableJournal) + val occurredAt = wallClock.nowEpochMillis() + val normalized = + sanitizedMutationFailure( + kind = kind, + detail = detail, + message = message, + occurredAtEpochMillis = occurredAt, + ) + + withContext(NonCancellable) { + val commit = + durable.storage.transaction { transaction -> + val failure = + transaction.appendFailure( + clientId = clientId, + clientSequence = entry.durableClientSequence, + generation = previous.currentGeneration, + kind = normalized.kind, + detail = normalized.detail, + message = normalized.message, + occurredAt = occurredAt, + ) + val parked = + previous.copyExecution( + phase = StoredExecutionPhase.PARKED, + activeFailureId = failure.failureId, + ) + transaction.advanceExecution(parked) + DurableParkCommit(failure = failure, execution = parked) + } + publishDurablePark(identity, entry, commit) + } + } + + /** Publishes the runtime half of a committed PARKED accepted-state handoff. */ + private suspend fun publishDurablePark( + identity: KeyIdentity, + entry: JournalEntry, + commit: DurableParkCommit, + ) { + val publicFailure = commit.failure.toPublicFailure() + durableExecutions[entry.mutationId] = commit.execution + phases[entry.mutationId] = MutationExecutionPhase.PARKED + completedAttempts[entry.mutationId] = commit.execution.attempt + drainFailures += publicFailure + deadLettersByMutationId[entry.mutationId] = + DeadLetter( + namespace = identity.namespace, + canonicalId = identity.canonicalId, + mutationId = entry.mutationId, + mutatorId = entry.mutatorId, + generation = commit.execution.currentGeneration, + attempts = commit.execution.attempt, + failure = publicFailure, + parkedAtEpochMillis = commit.failure.occurredAt, + ) + preAckParkCandidates.remove(entry.mutationId) + codecBlockedMutationIds.remove(entry.mutationId) + journal.retire(identity, entry.mutationId) + signalSink.emit(ProjectionRevisionKey(identity)) + val eventIdentity = + durableAttempts[entry.mutationId]?.toEventIdentity() + ?: requireNotNull(acceptedIntentIdentities[entry.mutationId]).toEventIdentity() + eventBus.tryEmit( + MutationParked( + mutationId = entry.mutationId, + identity = eventIdentity, + occurredAtEpochMillis = commit.failure.occurredAt, + generation = commit.execution.currentGeneration, + failure = publicFailure, + ), + ) + acceptedIntentIdentities.remove(entry.mutationId) + } + + private fun buildPushFromDurableAttempt( + key: K, + entry: JournalEntry, + attempt: MutationAttemptRecord, + ): MutationPush { + require(key.namespace.value == attempt.effectiveNamespace) { + "Resolved mutation key namespace does not match durable attempt identity." + } + require(key.canonicalId() == attempt.effectiveCanonicalId) { + "Resolved mutation key canonical id does not match durable attempt identity." + } + val codec = checkNotNull(valueCodec) + return MutationPush( + identity = MutationKeyIdentity(attempt.effectiveNamespace, attempt.effectiveCanonicalId), + key = key, + clientId = clientId, + clientSequence = entry.durableClientSequence, + retiredThroughSequence = attempt.advertisedRetiredThroughSequence, + mutationId = entry.mutationId, + generation = attempt.generation, + idempotencyKey = attempt.generationIdempotencyKey, + valueCodecVersion = attempt.valueCodecVersion, + base = attempt.decodeBase(codec), + mine = attempt.decodeMine(codec), + baseMeta = attempt.preconditionMetaOrNull(), + ) + } + + /** + * Validates one acknowledgement target and persists the complete ACKED receipt while the + * source lease is held. The engine-local target receipt is deliberately remembered before + * persistence: a rolled-back first receipt still pins an exact-generation retry target. + */ + private suspend fun recordDurableAckReceipt( + entry: JournalEntry, + attempt: MutationAttemptRecord, + ack: MutationAck, + ): DurableAckReceiptOutcome { + val receipt: + Pair = + durableAckAdmission.withLock { + val decision = + mutations.withLock { + decideDurableAckReceipt(entry, attempt, ack) + } + when (decision) { + is DurableAckReceiptDecision.Commit -> { + val record = + recordDurableAck(entry, attempt, ack, decision.aliasAdmission) + if (ack is MutationPresentAck) { + ack.canonicalKey?.let { canonical -> + cacheLiveKey(canonical.identity(), canonical) + } + } + clearDurableAckTargetReceipt(decision) + null to + MutationAcknowledged( + mutationId = entry.mutationId, + identity = attempt.toEventIdentity(), + occurredAtEpochMillis = record.receivedAt, + generation = record.generation, + presence = record.authoritativePresence, + ) + } + is DurableAckReceiptDecision.Reject -> decision to null + } + } + val rejection = receipt.first + if (rejection == null) { + eventBus.tryEmit(checkNotNull(receipt.second)) + return DurableAckReceiptOutcome.ACKED + } + + // Rejections never retain the ack admission gate across persistence or publication. + val outcome = + recordDurableAckProtocolRejection( + identity = rejection.source, + entry = entry, + rejection = rejection.rejection, + ) + if (outcome == DurableAckReceiptOutcome.PARKED) { + clearDurableAckTargetReceipt(rejection) + } + return outcome + } + + /** Admission and the process-local replay pin are the only work done under [mutations]. */ + private suspend fun decideDurableAckReceipt( + entry: JournalEntry, + attempt: MutationAttemptRecord, + ack: MutationAck, + ): DurableAckReceiptDecision { + val source = KeyIdentity(attempt.effectiveNamespace, attempt.effectiveCanonicalId) + val idempotencyKey = attempt.generationIdempotencyKey + val admission = + when (ack) { + is MutationPresentAck -> + aliasRouter.admit( + source = source, + claimed = ack.canonicalKey?.identity(), + idempotencyKey = idempotencyKey, + createdByClientId = clientId, + createdBySequence = entry.durableClientSequence, + createdAt = wallClock.nowEpochMillis(), + ) + is MutationAbsentAck -> null + } + if (admission is AliasAdmission.Rejected) { + return DurableAckReceiptDecision.Reject( + source = source, + rejection = admission, + idempotencyKey = idempotencyKey, + pinnedTarget = ackEffectiveTargetsByIdempotencyKey[idempotencyKey], + ) + } + val admitted = admission as? AliasAdmission.Admitted + val effectiveTarget = admitted?.effectiveTarget ?: source + val previousTarget = ackEffectiveTargetsByIdempotencyKey[idempotencyKey] + if (previousTarget != null && previousTarget != effectiveTarget) { + return DurableAckReceiptDecision.Reject( + source = source, + rejection = + AliasAdmission.Rejected( + detail = ALIAS_FAILURE_DETAIL_RETRY_TARGET_MISMATCH, + message = + "Retry of idempotency key '$idempotencyKey' acknowledged canonical " + + "target (${effectiveTarget.namespace}, " + + "${effectiveTarget.canonicalId}) but " + + "(${previousTarget.namespace}, ${previousTarget.canonicalId}) " + + "was previously acknowledged.", + ), + idempotencyKey = idempotencyKey, + pinnedTarget = previousTarget, + ) + } + ackEffectiveTargetsByIdempotencyKey[idempotencyKey] = effectiveTarget + return DurableAckReceiptDecision.Commit( + aliasAdmission = admitted, + idempotencyKey = idempotencyKey, + pinnedTarget = effectiveTarget, + ) + } + + /** A terminal receipt outcome cannot retain the process-local replay pin. */ + private suspend fun clearDurableAckTargetReceipt(decision: DurableAckReceiptDecision) { + val pinnedTarget = decision.pinnedTarget ?: return + withContext(NonCancellable) { + mutations.withLock { + if ( + ackEffectiveTargetsByIdempotencyKey[decision.idempotencyKey] == pinnedTarget + ) { + ackEffectiveTargetsByIdempotencyKey.remove(decision.idempotencyKey) + } + } + } + } + + private suspend fun recordDurableAckProtocolRejection( + identity: KeyIdentity, + entry: JournalEntry, + rejection: AliasAdmission.Rejected, + ): DurableAckReceiptOutcome { + return if ( + rejection.detail == ALIAS_FAILURE_DETAIL_RETRY_TARGET_MISMATCH || + rejection.detail == ALIAS_FAILURE_DETAIL_RETARGET || + rejection.detail == ALIAS_FAILURE_DETAIL_CYCLE + ) { + parkDurableAckProtocolFailure(identity, entry, rejection) + DurableAckReceiptOutcome.PARKED + } else { + // A cross-namespace rejection deliberately halts without parking. + recordDurableProtocolFailure(entry, rejection) + DurableAckReceiptOutcome.HALTED + } + } + + /** One PROTOCOL failure and the completed INFLIGHT attempt park atomically. */ + private suspend fun parkDurableAckProtocolFailure( + identity: KeyIdentity, + entry: JournalEntry, + rejection: AliasAdmission.Rejected, + ) { + val durable = requireNotNull(durableJournal) + val previous = requireNotNull(durableExecutions[entry.mutationId]) + require(previous.phase == StoredExecutionPhase.INFLIGHT) { + "Acknowledgement protocol parking requires INFLIGHT, but was ${previous.phase}." + } + val occurredAt = wallClock.nowEpochMillis() + val normalized = + sanitizedMutationFailure( + kind = MutationFailureKind.PROTOCOL, + detail = rejection.detail, + message = rejection.message, + occurredAtEpochMillis = occurredAt, + ) + withContext(NonCancellable) { + val commit = + durable.storage.transaction { transaction -> + val failure = + transaction.appendFailure( + clientId = clientId, + clientSequence = entry.durableClientSequence, + generation = previous.currentGeneration, + kind = normalized.kind, + detail = normalized.detail, + message = normalized.message, + occurredAt = occurredAt, + ) + val parked = + previous.copyExecution( + phase = StoredExecutionPhase.PARKED, + attempt = previous.attempt + 1, + lastAttemptAt = occurredAt, + activeFailureId = failure.failureId, + ) + transaction.advanceExecution(parked) + DurableParkCommit(failure = failure, execution = parked) + } + publishDurablePark(identity, entry, commit) + } + } + + private suspend fun recordDurableProtocolFailure( + entry: JournalEntry, + rejection: AliasAdmission.Rejected, + ) { + val durable = requireNotNull(durableJournal) + val previous = requireNotNull(durableExecutions[entry.mutationId]) + val occurredAt = wallClock.nowEpochMillis() + val normalized = + sanitizedMutationFailure( + kind = MutationFailureKind.PROTOCOL, + detail = rejection.detail, + message = rejection.message, + occurredAtEpochMillis = occurredAt, + ) + val ready = + previous.copyExecution( + phase = StoredExecutionPhase.READY, + attempt = previous.attempt + 1, + lastAttemptAt = occurredAt, + ) + durable.storage.transaction { transaction -> + transaction.appendFailure( + clientId, + entry.durableClientSequence, + previous.currentGeneration, + normalized.kind, + normalized.detail, + normalized.message, + occurredAt, + ) + transaction.advanceExecution(ready) + } + drainFailures += normalized + durableExecutions[entry.mutationId] = ready + phases[entry.mutationId] = MutationExecutionPhase.READY + completedAttempts[entry.mutationId] = ready.attempt + val attempt = requireNotNull(durableAttempts[entry.mutationId]) + eventBus.tryEmit( + MutationFailed( + mutationId = entry.mutationId, + identity = attempt.toEventIdentity(), + occurredAtEpochMillis = occurredAt, + generation = ready.currentGeneration, + state = MutationPendingState.PENDING, + failure = normalized, + ), + ) + } + + private suspend fun recordDurableAck( + entry: JournalEntry, + attempt: MutationAttemptRecord, + ack: MutationAck, + aliasAdmission: AliasAdmission.Admitted? = null, + ): MutationAckRecord { + val durable = requireNotNull(durableJournal) + val codec = checkNotNull(valueCodec) + val receivedAt = wallClock.nowEpochMillis() + val record = + when (ack) { + is MutationPresentAck -> + MutationAckRecord( + clientId, + entry.durableClientSequence, + attempt.generation, + MutationPresenceState.PRESENT, + codec.encodeCopied(ack.authoritative), + valueCodecVersion, + ack.etag, + ack.canonicalKey?.namespace?.value, + ack.canonicalKey?.canonicalId(), + receivedAt, + ) + is MutationAbsentAck -> + MutationAckRecord( + clientId, + entry.durableClientSequence, + attempt.generation, + MutationPresenceState.ABSENT, + null, + valueCodecVersion, + ack.etag, + null, + null, + receivedAt, + ) + } + val previous = requireNotNull(durableExecutions[entry.mutationId]) + val acknowledged = + previous.copyExecution( + phase = StoredExecutionPhase.ACKED, + attempt = previous.attempt + 1, + lastAttemptAt = receivedAt, + ) + val tombstone = + if (ack is MutationAbsentAck) { + MutationKeyTombstoneRecord( + namespace = attempt.effectiveNamespace, + canonicalId = attempt.effectiveCanonicalId, + createdByClientId = clientId, + createdBySequence = entry.durableClientSequence, + state = MutationTombstoneState.PENDING, + createdAt = receivedAt, + activatedAt = null, + supersededByClientId = null, + supersededBySequence = null, + supersededAt = null, + ) + } else { + null + } + durable.storage.transaction { transaction -> + aliasAdmission?.pendingRecord?.let(transaction::insertAlias) + tombstone?.let(transaction::insertTombstone) + transaction.insertAck(record) + transaction.advanceExecution(acknowledged) + } + if (aliasAdmission != null) { + publishDurableAliasAdmission(aliasAdmission) + } + durableAcks[entry.mutationId] = record + durableExecutions[entry.mutationId] = acknowledged + phases[entry.mutationId] = MutationExecutionPhase.ACKED + completedAttempts[entry.mutationId] = acknowledged.attempt + tombstone?.let { hydratedTombstones += it } + return record + } + + /** Publishes only the pending edge; durable retry receipts are owned by the engine map. */ + private fun publishDurableAliasAdmission(admission: AliasAdmission.Admitted) { + val durable = requireNotNull(durableJournal) + durable.runtimeState.updateAliases { edges -> + admission.pendingRecord?.let { record -> + edges + + (KeyIdentity(record.sourceNamespace, record.sourceCanonicalId) to + checkNotNull(admission.redirect)) + } ?: edges + } + } + + private fun durableAckEffectiveIdentity( + key: K, + entry: JournalEntry, + ): KeyIdentity { + val ack = requireNotNull(durableAcks[entry.mutationId]) + return if ( + ack.authoritativePresence == MutationPresenceState.PRESENT && + ack.canonicalTargetNamespace != null + ) { + aliasRouter.terminalOf( + KeyIdentity( + ack.canonicalTargetNamespace, + checkNotNull(ack.canonicalTargetId), + ), + ) + } else { + key.identity() + } + } + + /** Silently probes a hydration-blocked ACKED value without duplicating CODEC evidence. */ + private fun probeDurableAckCodec(entry: JournalEntry): Boolean { + val ack = requireNotNull(durableAcks[entry.mutationId]) + if (ack.authoritativePresence == MutationPresenceState.ABSENT) return true + return try { + checkNotNull(valueCodec).decodeCopied( + ack.valueCodecVersion, + checkNotNull(ack.authoritativeBlob), + ) + true + } catch (failure: Throwable) { + if (failure is CancellationException) throw failure + false + } + } + + private suspend fun resumeDurableAck( + key: K, + effectiveKey: K, + entry: JournalEntry, + ): K? { + val ack = requireNotNull(durableAcks[entry.mutationId]) + return when (ack.authoritativePresence) { + MutationPresenceState.PRESENT -> { + try { + val authoritative = + checkNotNull(valueCodec).decodeCopied( + ack.valueCodecVersion, + checkNotNull(ack.authoritativeBlob), + ) + handle.apply(effectiveKey, authoritative) + handle.confirmFresh(effectiveKey, ack.etag) + } catch (failure: Throwable) { + if (failure is CancellationException) throw failure + throwPostAckFailureWithEvidence( + primary = failure, + entry = entry, + kind = MutationFailureKind.ADOPTION, + detail = "adoption-failed", + message = + failure.message ?: "Mutation adoption failed without a message.", + ) + } + persistDurableEffectsPending(entry) + resumeDurableEffectsPending(key, effectiveKey, entry) + } + MutationPresenceState.ABSENT -> { + try { + absentAdoption(key) + } catch (failure: Throwable) { + if (failure is CancellationException) throw failure + throwPostAckFailureWithEvidence( + primary = failure, + entry = entry, + kind = MutationFailureKind.ADOPTION, + detail = "adoption-failed", + message = + failure.message ?: "Mutation adoption failed without a message.", + ) + } + persistDurableEffectsPending(entry) + resumeDurableEffectsPending(key, key, entry) + } + } + } + + /** Executes one stable pending-effect snapshot before attempting retirement finalization. */ + private suspend fun resumeDurableEffectsPending( + key: K, + effectiveKey: K, + entry: JournalEntry, + ): K? { + val pendingEffects = + durableEffectRows[entry.mutationId] + .orEmpty() + .filter { effect -> effect.disposition == MutationEffectDisposition.PENDING } + .sortedBy { effect -> effect.effectIndex } + var firstFailure: RetryablePostAckFailure? = null + for (effect in pendingEffects) { + try { + when (effect.kind) { + MutationEffectKind.KEY -> { + val declaredIdentity = + KeyIdentity(effect.namespace, checkNotNull(effect.canonicalId)) + val targetIdentity = effectTargetIdentity(declaredIdentity, entry) + val targetKey = + resolveExecutionIdentity( + acknowledged = targetIdentity, + postAckEntry = entry, + ) + executeDurableEffectTarget(entry) { + handle.markStale(targetKey) + } + } + + MutationEffectKind.NAMESPACE -> + executeDurableEffectTarget(entry) { + namespaceInvalidation(StoreNamespace(effect.namespace)) + } + } + persistDurableEffectApplied(entry, effect) + } catch (failure: RetryablePostAckFailure) { + val retained = firstFailure + if (retained == null) { + firstFailure = failure + } else if (retained.exposed !== failure.exposed) { + retained.exposed.addSuppressed(failure.exposed) + } + } + } + firstFailure?.let { failure -> throw failure } + val retired = + if (effectiveKey.identity() == key.identity()) { + finalizeDurableRetirement(key, entry) + } else { + finalizeDurableAliasRetirement(key, effectiveKey, entry) + } + return effectiveKey.takeIf { retired } + } + + /** Active routing plus this acknowledgement's private PENDING redirect for effect work. */ + private fun effectTargetIdentity( + declared: KeyIdentity, + entry: JournalEntry, + ): KeyIdentity { + val activeTerminal = aliasRouter.terminalOf(declared) + val attempt = requireNotNull(durableAttempts[entry.mutationId]) + val acknowledgementSource = + KeyIdentity(attempt.effectiveNamespace, attempt.effectiveCanonicalId) + if (activeTerminal != acknowledgementSource) return activeTerminal + val pending = aliasRouter.edgeFor(acknowledgementSource) + return if ( + pending != null && + pending.state == AliasEdgeState.PENDING && + pending.createdByClientId == clientId && + pending.createdBySequence == entry.durableClientSequence + ) { + aliasRouter.terminalOf(pending.target) + } else { + activeTerminal + } + } + + /** Target failures stay replayable and surface through Store's sanctioned persistence form. */ + private suspend fun executeDurableEffectTarget( + entry: JournalEntry, + target: suspend () -> Unit, + ) { + try { + target() + } catch (failure: Throwable) { + if (failure is CancellationException) throw failure + val message = failure.message ?: "Mutation effect target failed without a message." + val primary = + if (failure is StoreException) { + failure + } else { + StoreResults.exception( + StoreResults.persistenceError(message, failure), + failure, + ) + } + throwPostAckFailureWithEvidence( + primary = primary, + entry = entry, + kind = MutationFailureKind.EFFECT, + detail = "effect-target-failed", + message = message, + ) + } + } + + /** Marks one externally completed effect APPLIED; cache publication follows commit. */ + private suspend fun persistDurableEffectApplied( + entry: JournalEntry, + effect: StoredEffectRecord, + ) { + val durable = requireNotNull(durableJournal) + val applied = effect.appliedAt(wallClock.nowEpochMillis()) + retryablePostAckPersistence(entry) { + durable.storage.transaction { transaction -> + transaction.advanceEffect(applied) + } + } + durableEffectRows[entry.mutationId] = + durableEffectRows[entry.mutationId] + .orEmpty() + .map { current -> + if (current.effectIndex == applied.effectIndex) applied else current + } + val attempt = requireNotNull(durableAttempts[entry.mutationId]) + eventBus.tryEmit( + MutationEffectApplied( + mutationId = entry.mutationId, + identity = attempt.toEventIdentity(), + occurredAtEpochMillis = checkNotNull(applied.completedAt), + generation = attempt.generation, + effectIndex = applied.effectIndex, + ), + ) + } + + private suspend fun resolveExecutionIdentity( + acknowledged: KeyIdentity, + acknowledgedKey: K? = null, + postAckEntry: JournalEntry? = null, + ): K { + val terminal = aliasRouter.terminalOf(acknowledged) + acknowledgedKey?.let { candidate -> + if ( + candidate.namespace.value == terminal.namespace && + candidate.canonicalId() == terminal.canonicalId + ) { + cacheLiveKey(terminal, candidate) + return candidate + } + } + cachedLiveKey(terminal)?.let { cached -> return cached } + return when (val resolution = resolveForDrain(terminal)) { + is TerminalKeyResolution.Resolved -> resolution.key + is TerminalKeyResolution.Failed -> { + val heldPostAck = + postAckEntry?.takeIf { entry -> + when (durableExecutions[entry.mutationId]?.phase) { + StoredExecutionPhase.ACKED, + StoredExecutionPhase.EFFECTS_PENDING, + -> true + else -> false + } + } + if (heldPostAck == null) { + resolution.cause?.let { throw it } + throw IllegalStateException(resolution.message) + } + val conversionFailure = + StoreResults.exception( + StoreResults.conversionError(resolution.message, resolution.cause), + resolution.cause, + ) + throwPostAckFailureWithEvidence( + primary = conversionFailure, + entry = heldPostAck, + kind = MutationFailureKind.IDENTITY, + detail = resolution.detail, + message = resolution.message, + ) + } + } + } + + /** Evidence failure is secondary; only cancellation may replace the triggering failure. */ + private suspend fun throwPostAckFailureWithEvidence( + primary: Throwable, + entry: JournalEntry, + kind: MutationFailureKind, + detail: String, + message: String, + ): Nothing { + try { + appendPostAckEvidence(entry, kind, detail, message) + } catch (evidenceFailure: Throwable) { + if (evidenceFailure is CancellationException) throw evidenceFailure + if (evidenceFailure !== primary) primary.addSuppressed(evidenceFailure) + } + throw retryablePostAckFailure(entry, primary) + } + + /** Marks only a known external or persistence failure at a retained post-ack owner. */ + private fun retryablePostAckFailure( + entry: JournalEntry, + exposed: Throwable, + ): RetryablePostAckFailure { + if (exposed is CancellationException) throw exposed + if (exposed is RetryablePostAckFailure) throw exposed + val execution = requireNotNull(durableExecutions[entry.mutationId]) + require( + execution.phase == StoredExecutionPhase.ACKED || + execution.phase == StoredExecutionPhase.EFFECTS_PENDING, + ) { + "Retryable post-ack failure requires ACKED or EFFECTS_PENDING, but was " + + "${execution.phase}." + } + require(execution.activeFailureId == null) { + "Retryable post-ack failure cannot retain an active parked failure." + } + return RetryablePostAckFailure( + exposed = exposed, + mutationId = entry.mutationId, + retainedPhase = execution.phase, + ) + } + + /** Wraps only the storage transaction, never postcommit publication or invariants around it. */ + private suspend fun retryablePostAckPersistence( + entry: JournalEntry, + transaction: suspend () -> R, + ): R = + try { + transaction() + } catch (failure: NonRetryablePostAckInvariantFailure) { + throw failure.exposed + } catch (failure: Throwable) { + throw retryablePostAckFailure(entry, failure) + } + + /** Distinguishes pure durable-shape/programming failures from adapter persistence failures. */ + private fun nonRetryablePostAckInvariant(invariant: () -> R): R = + try { + invariant() + } catch (failure: Throwable) { + if (failure is CancellationException) throw failure + if (failure is NonRetryablePostAckInvariantFailure) throw failure + throw NonRetryablePostAckInvariantFailure(failure) + } + + /** Global continuation is legal only while the same unparked durable owner remains held. */ + private fun retainsRetryablePostAckOwner(failure: RetryablePostAckFailure): Boolean { + val execution = durableExecutions[failure.mutationId] ?: return false + return execution.phase == failure.retainedPhase && + execution.activeFailureId == null && + ( + execution.phase == StoredExecutionPhase.ACKED || + execution.phase == StoredExecutionPhase.EFFECTS_PENDING + ) + } + + /** + * Appends post-ack evidence without changing execution, attempts, routing, or membership. + * The inspection carrier is published only after the evidence transaction commits. + */ + private suspend fun appendPostAckEvidence( + entry: JournalEntry, + kind: MutationFailureKind, + detail: String, + message: String, + ) { + require( + kind == MutationFailureKind.IDENTITY || + kind == MutationFailureKind.CODEC || + kind == MutationFailureKind.ADOPTION || + kind == MutationFailureKind.EFFECT, + ) { "Post-ack evidence cannot record $kind." } + val durable = requireNotNull(durableJournal) + val execution = requireNotNull(durableExecutions[entry.mutationId]) + require( + execution.phase == StoredExecutionPhase.ACKED || + execution.phase == StoredExecutionPhase.EFFECTS_PENDING, + ) { "Post-ack evidence requires ACKED or EFFECTS_PENDING, but was ${execution.phase}." } + val occurredAt = wallClock.nowEpochMillis() + val normalized = + sanitizedMutationFailure( + kind = kind, + detail = detail, + message = message, + occurredAtEpochMillis = occurredAt, + ) + durable.storage.transaction { transaction -> + transaction.appendFailure( + clientId = clientId, + clientSequence = entry.durableClientSequence, + generation = execution.currentGeneration, + kind = normalized.kind, + detail = normalized.detail, + message = normalized.message, + occurredAt = occurredAt, + ) + } + drainFailures += normalized + val attempt = requireNotNull(durableAttempts[entry.mutationId]) + val publicState = + when (execution.phase) { + StoredExecutionPhase.ACKED -> MutationPendingState.ADOPTING + StoredExecutionPhase.EFFECTS_PENDING -> + MutationPendingState.APPLYING_EFFECTS + else -> error("Post-ack evidence published from ${execution.phase}.") + } + eventBus.tryEmit( + MutationFailed( + mutationId = entry.mutationId, + identity = attempt.toEventIdentity(), + occurredAtEpochMillis = occurredAt, + generation = execution.currentGeneration, + state = publicState, + failure = normalized, + ), + ) + } + + /** + * Atomically skips every pending effect, retires from REFRESH_REQUIRED, and advances + * the contiguous local prefix. The durable commit and complete projection handoff are one + * cancellation-safe operation; no acknowledgement, alias, tombstone, or invalidation runs. + */ + private suspend fun retireDurableServerWins( + identity: KeyIdentity, + entry: JournalEntry, + ) { + withContext(NonCancellable) { + val commit = + retirementPass.withLock { + val durable = requireNotNull(durableJournal) + val sequence = entry.durableClientSequence + val previous = requireNotNull(durableExecutions[entry.mutationId]) + require(previous.phase == StoredExecutionPhase.REFRESH_REQUIRED) { + "ServerWins requires REFRESH_REQUIRED, but was ${previous.phase}." + } + val retiredAt = wallClock.nowEpochMillis() + val committed = + durable.storage.transaction { transaction -> + val skippedEffects = mutableListOf() + val effects = + transaction + .effects(clientId) + .filter { effect -> effect.clientSequence == sequence } + .sortedBy { effect -> effect.effectIndex } + .map { effect -> + if ( + effect.disposition == + MutationEffectDisposition.PENDING + ) { + effect.skippedAt(retiredAt).also { skipped -> + transaction.advanceEffect(skipped) + skippedEffects += skipped + } + } else { + effect + } + } + val retired = + previous.copyExecution( + phase = StoredExecutionPhase.RETIRED, + retiredAt = retiredAt, + ) + transaction.advanceExecution(retired) + val client = requireNotNull(transaction.client(clientId)) + val gaps = retiredSequences.toMutableSet().apply { add(sequence) } + var prefix = client.retiredThroughSequence + while (gaps.remove(prefix + 1L)) prefix += 1L + transaction.advanceClient( + MutationClientRecord( + recordVersion = client.recordVersion, + clientId = client.clientId, + lastAllocatedSequence = client.lastAllocatedSequence, + retiredThroughSequence = prefix, + serverConfirmedRetiredThroughSequence = + client.serverConfirmedRetiredThroughSequence, + createdAt = client.createdAt, + ), + ) + DurableServerWinsCommit( + execution = retired, + effects = effects, + skippedEffects = skippedEffects, + retiredThroughSequence = prefix, + serverConfirmedRetiredThroughSequence = + client.serverConfirmedRetiredThroughSequence, + ) + } + + // Durable release precedes runtime membership removal. The namespace process + // lease remains held by the enclosing pass until cleanup and signaling finish. + journal.retire(identity, entry.mutationId) + durableExecutions[entry.mutationId] = committed.execution + durableEffectRows[entry.mutationId] = committed.effects + retiredSequences += sequence + while (retiredSequences.remove(retiredThroughSequence + 1L)) { + retiredThroughSequence += 1L + } + check(retiredThroughSequence == committed.retiredThroughSequence) + serverConfirmedRetiredThroughSequence = + committed.serverConfirmedRetiredThroughSequence + phases.remove(entry.mutationId) + completedAttempts.remove(entry.mutationId) + committed + } + signalSink.emit(ProjectionRevisionKey(identity)) + val attempt = requireNotNull(durableAttempts[entry.mutationId]) + commit.skippedEffects.forEach { effect -> + eventBus.tryEmit( + MutationEffectSkipped( + mutationId = entry.mutationId, + identity = attempt.toEventIdentity(), + occurredAtEpochMillis = checkNotNull(effect.completedAt), + generation = attempt.generation, + effectIndex = effect.effectIndex, + ), + ) + } + eventBus.tryEmit( + MutationRetired( + mutationId = entry.mutationId, + identity = attempt.toEventIdentity(), + occurredAtEpochMillis = checkNotNull(commit.execution.retiredAt), + generation = commit.execution.currentGeneration, + retiredThroughSequence = commit.retiredThroughSequence, + ), + ) + } + } + + private suspend fun finalizeDurableRetirement( + key: K, + entry: JournalEntry, + ): Boolean = + withContext(NonCancellable) { + val commit = + retirementPass.withLock { + val commit = + persistDurableRetirementLocked( + entry = entry, + effectiveIdentity = key.identity(), + aliasActivation = null, + ) ?: return@withLock null + journal.retire(key.identity(), entry.mutationId) + publishDurableTombstoneReplacements(commit) + publishDurableRetirementAccounting(entry, commit) + phases.remove(entry.mutationId) + completedAttempts.remove(entry.mutationId) + commit + } + if (commit == null) { + return@withContext false + } + signalSink.emit(key) + publishRetiredEvent(entry, commit) + true + } + + private suspend fun finalizeDurableAliasRetirement( + sourceKey: K, + targetKey: K, + entry: JournalEntry, + ): Boolean { + val source = sourceKey.identity() + val target = targetKey.identity() + return withContext(NonCancellable) { + val commit = + retirementPass.withLock { + val activation = aliasRouter.activationRecord(source, wallClock.nowEpochMillis()) + val commit = + persistDurableRetirementLocked( + entry = entry, + effectiveIdentity = target, + aliasActivation = activation, + ) ?: return@withLock null + val aliasPublication = requireNotNull(commit.aliasPublication) + mutations.withLock { + aliasPublicationMutex.withLock { + requireNotNull(durableJournal).publishAliasRetirement( + source = aliasPublication.source, + terminalTarget = aliasPublication.terminalTarget, + retiredMutationId = entry.mutationId, + ) + publishDurableTombstoneReplacements(commit) + publishDurableRetirementAccounting(entry, commit) + cacheLiveKey(aliasPublication.terminalTarget, targetKey) + phases.remove(entry.mutationId) + completedAttempts.remove(entry.mutationId) + emittedProjectionStampSignal(aliasPublication.terminalTarget).update { stamp -> + stamp + 1L + } + aliasRevisionSignal(aliasPublication.source).update { revision -> + revision + 1L + } + bumpResolutionPulse(aliasPublication.source) + bumpResolutionPulse(aliasPublication.terminalTarget) + } + } + commit + } + if (commit == null) return@withContext false + signalSink.emit(sourceKey) + signalSink.emit(targetKey) + publishRetiredEvent(entry, commit) + true + } + } + + /** Commits the adoption advance independently of retirement finalization. */ + private suspend fun persistDurableEffectsPending(entry: JournalEntry) { + val durable = requireNotNull(durableJournal) + val previous = requireNotNull(durableExecutions[entry.mutationId]) + require(previous.phase == StoredExecutionPhase.ACKED) { + "Durable adoption advance requires ACKED, but was ${previous.phase}." + } + val effectsPending = previous.copyExecution(phase = StoredExecutionPhase.EFFECTS_PENDING) + retryablePostAckPersistence(entry) { + durable.storage.transaction { transaction -> + transaction.advanceExecution(effectsPending) + } + } + durableExecutions[entry.mutationId] = effectsPending + phases[entry.mutationId] = MutationExecutionPhase.EFFECTS_PENDING + val attempt = requireNotNull(durableAttempts[entry.mutationId]) + val ack = requireNotNull(durableAcks[entry.mutationId]) + eventBus.tryEmit( + MutationAdopted( + mutationId = entry.mutationId, + identity = attempt.toEventIdentity(), + occurredAtEpochMillis = wallClock.nowEpochMillis(), + generation = effectsPending.currentGeneration, + presence = ack.authoritativePresence, + ), + ) + } + + private suspend fun persistDurableRetirementLocked( + entry: JournalEntry, + effectiveIdentity: KeyIdentity, + aliasActivation: MutationKeyAliasRecord?, + ): DurableRetirementCommit? { + val durable = requireNotNull(durableJournal) + val sequence = entry.durableClientSequence + val previous = requireNotNull(durableExecutions[entry.mutationId]) + require(previous.phase == StoredExecutionPhase.EFFECTS_PENDING) { + "Durable retirement finalization requires EFFECTS_PENDING, but was ${previous.phase}." + } + val retiredAt = wallClock.nowEpochMillis() + var commit: DurableRetirementCommit? = null + retryablePostAckPersistence(entry) { + durable.storage.transaction { transaction -> + val effectsPending = + transaction.effects(clientId).any { effect -> + effect.clientSequence == sequence && + effect.disposition == + org.mobilenativefoundation.store6.mutations.storage.MutationEffectDisposition.PENDING + } + if (effectsPending) return@transaction + aliasActivation?.let(transaction::advanceAlias) + val aliases = transaction.aliases() + val tombstones = transaction.tombstones() + val client = transaction.client(clientId) + val (proposedCommit, advancedClient) = + nonRetryablePostAckInvariant { + val aliasesBySource = aliases.toAliasEdgesBySource() + val tombstoneActivation = + tombstones + .singleOrNull { tombstone -> + tombstone.createdByClientId == clientId && + tombstone.createdBySequence == sequence && + tombstone.state == MutationTombstoneState.PENDING + }?.activatedAt(retiredAt) + val supersededTombstones = + tombstones + .filter { tombstone -> + tombstone.state == MutationTombstoneState.ACTIVE && + terminalIdentity( + tombstone.identity(), + aliasesBySource, + ) == effectiveIdentity + } + .map { tombstone -> + tombstone.supersededBy( + successorClientId = clientId, + successorSequence = sequence, + supersededAt = retiredAt, + ) + } + val currentClient = requireNotNull(client) + val gaps = retiredSequences.toMutableSet().apply { add(sequence) } + var prefix = currentClient.retiredThroughSequence + while (gaps.remove(prefix + 1L)) prefix += 1L + val retiredRecord = + previous.copyExecution( + phase = StoredExecutionPhase.RETIRED, + retiredAt = retiredAt, + ) + DurableRetirementCommit( + execution = retiredRecord, + retiredThroughSequence = prefix, + serverConfirmedRetiredThroughSequence = + currentClient.serverConfirmedRetiredThroughSequence, + tombstoneReplacements = + supersededTombstones + listOfNotNull(tombstoneActivation), + aliasPublication = + aliasActivation?.let { committedAlias -> + DurableAliasPublication( + activation = committedAlias, + terminalTarget = effectiveIdentity, + ) + }, + ) to + MutationClientRecord( + recordVersion = currentClient.recordVersion, + clientId = currentClient.clientId, + lastAllocatedSequence = currentClient.lastAllocatedSequence, + retiredThroughSequence = prefix, + serverConfirmedRetiredThroughSequence = + currentClient.serverConfirmedRetiredThroughSequence, + createdAt = currentClient.createdAt, + ) + } + proposedCommit.tombstoneReplacements.forEach(transaction::advanceTombstone) + transaction.advanceExecution(proposedCommit.execution) + transaction.advanceClient(advancedClient) + commit = proposedCommit + } + } + return commit + } + + /** One cache replacement, only after routing/membership is coherent. */ + private fun publishDurableTombstoneReplacements(commit: DurableRetirementCommit) { + if (commit.tombstoneReplacements.isNotEmpty()) { + hydratedTombstones.replaceAllBy( + replacements = commit.tombstoneReplacements, + sameRecord = { current, replacement -> + current.sameGenerationAs(replacement) + }, + ) + } + } + + /** Process bookkeeping follows routing and tombstone publication. */ + private fun publishDurableRetirementAccounting( + entry: JournalEntry, + commit: DurableRetirementCommit, + ) { + durableExecutions[entry.mutationId] = commit.execution + retiredSequences += entry.durableClientSequence + while (retiredSequences.remove(retiredThroughSequence + 1L)) { + retiredThroughSequence += 1L + } + check(retiredThroughSequence == commit.retiredThroughSequence) + serverConfirmedRetiredThroughSequence = commit.serverConfirmedRetiredThroughSequence + } + + /** Publishes one completed retirement only after membership, routing, and signals cohere. */ + private fun publishRetiredEvent( + entry: JournalEntry, + commit: DurableRetirementCommit, + ) { + val attempt = requireNotNull(durableAttempts[entry.mutationId]) + eventBus.tryEmit( + MutationRetired( + mutationId = entry.mutationId, + identity = attempt.toEventIdentity(), + occurredAtEpochMillis = checkNotNull(commit.execution.retiredAt), + generation = commit.execution.currentGeneration, + retiredThroughSequence = commit.retiredThroughSequence, + ), + ) + } + + /** + * The lowest pending durable client sequence at [identity] within this pass's enqueue bound: + * the pass drains the prefix that existed when it started and never chases intents enqueued + * behind it. Ties (direct journal appends in module tests use the default sequence) + * keep first-append order. + */ + private suspend fun nextEligibleHead( + identity: KeyIdentity, + sequenceBound: Long, + overrideBackoff: Boolean, + initialEntry: JournalEntry? = null, + ): DrainHead? = + mutations.withLock { + val tombstones = hydratedTombstones.toList() + val snapshot = journal.runtimeSnapshot() + val entries = snapshot.entries[identity].orEmpty() + val aliases = snapshot.aliases + val owner = durableNamespaceOwners()[identity.namespace] + if (owner != null && owner.identity != terminalIdentity(identity, aliases)) { + return@withLock null + } + val pending = + replayableEntries(identity, entries, aliases, tombstones) + .filter { entry -> entry.clientSequence <= sequenceBound } + val hinted = + initialEntry?.takeIf { candidate -> + pending.any { row -> row.mutationId == candidate.mutationId } + } + val entry = + if (owner != null) { + pending.singleOrNull { row -> + row.durableClientSequence == owner.clientSequence + } ?: return@withLock null + } else { + hinted ?: pending.minByOrNull { row -> row.clientSequence } + ?: return@withLock null + } + if (entry.mutationId in pendingEnqueuePublications) return@withLock null + if (!overrideBackoff && hinted == null && !isBackoffEligible(entry)) { + return@withLock null + } + DrainHead( + entry = entry, + phase = phases[entry.mutationId] ?: MutationExecutionPhase.UNPREPARED, + ) + } + + /** + * Eligibility derived only from durable facts at pass time. READY and REFRESH_REQUIRED + * share the same schedule; every other phase is immediately eligible. + * Jitter is uniform over the inclusive `[0, computed]` interval, drawn once by the caller's + * entry selection for this pass, and never persisted. + */ + private fun isBackoffEligible(entry: JournalEntry): Boolean { + val execution = durableExecutions[entry.mutationId] ?: return true + if ( + execution.phase != StoredExecutionPhase.READY && + execution.phase != StoredExecutionPhase.REFRESH_REQUIRED + ) { + return true + } + if (execution.attempt == 0) return true + val lastAttemptAt = checkNotNull(execution.lastAttemptAt) { + "A retryable execution with completed attempts must retain lastAttemptAt." + } + val computed = backoffWindowMillis(execution.attempt) + val jitter = backoffRandom.nextLong(computed + 1L) + val eligibleAt = + if (lastAttemptAt > Long.MAX_VALUE - jitter) Long.MAX_VALUE else lastAttemptAt + jitter + return wallClock.nowEpochMillis() >= eligibleAt + } + + private fun backoffWindowMillis(attempt: Int): Long { + require(attempt >= 1) + var computed = BACKOFF_BASE_MILLIS + repeat(attempt - 1) { + computed = minOf(BACKOFF_CAP_MILLIS, computed * 2L) + } + return computed + } + + /** + * The ordered base-capture loop. A present value accepts the first (pre-value) status + * metadata, which may match or lag but cannot lead the value under Store's commit ordering. + * On a missing value, status is read again and absence is accepted only when BOTH bracketing + * statuses carry no metadata; otherwise a concurrent fetch-deletion or key/namespace/all-clear + * window is open and the loop retries. The loop, not any single bracket, is the correctness + * mechanism. + */ + private suspend fun captureBase(key: K): CapturedBase { + while (true) { + val leading = bookkeeper.status(key) + val confirmed = baseReader(key) + if (confirmed != null) { + return CapturedBase( + presence = MutationPresence.Present(confirmed), + meta = snapshotMeta(leading?.meta), + ) + } + val trailing = bookkeeper.status(key) + if (leading?.meta == null && trailing?.meta == null) { + return CapturedBase(presence = MutationPresence.Absent, meta = null) + } + } + } + + private fun project( + base: MutationPresence, + entry: JournalEntry, + ): ProjectionOutcome { + if (entry.mutationId in codecBlockedMutationIds || entry.args === UnavailableMutationArgs) { + return ProjectionOutcome(value = base, advanced = false, failure = null) + } + val registration = + registry.registrations[entry.mutatorId] + ?: return ProjectionOutcome(value = base, advanced = false, failure = null) + return try { + val result = registration.project(base, entry.args) + if (result == null) { + // A null projector result means decline only — passthrough in projection, + // halt of the same-key suffix in drain, never a deletion. + ProjectionOutcome(value = base, advanced = false, failure = null) + } else { + ProjectionOutcome(value = result, advanced = true, failure = null) + } + } catch (failure: Throwable) { + // Containment swallows everything including CancellationException: a rethrow out of + // Overlay.apply terminalizes the key's projected streams permanently. + poisonSink.tryEmit( + PoisonedIntent( + mutationId = entry.mutationId, + mutatorId = entry.mutatorId, + failure = failure, + ), + ) + ProjectionOutcome(value = base, advanced = false, failure = failure) + } + } + + /** + * Builds the immutable in-memory attempt generation: stable client identity and + * sequence, the advertised contiguous retired prefix, generation-scoped deterministic + * idempotency key, the retained value-codec version, and base/mine rebuilt through the + * codec's defensive-copy boundaries so a mutating server cannot alter reconstruction. + */ + private fun buildPush( + key: K, + entry: JournalEntry, + base: MutationPresence, + mine: MutationPresence, + baseMeta: StoreMeta?, + ): MutationPush = + MutationPush( + identity = MutationKeyIdentity(key.namespace.value, key.canonicalId()), + key = key, + clientId = clientId, + clientSequence = entry.clientSequence, + retiredThroughSequence = retiredThroughSequence, + mutationId = entry.mutationId, + generation = IN_MEMORY_GENERATION, + idempotencyKey = "$clientId:${entry.clientSequence}:g$IN_MEMORY_GENERATION", + valueCodecVersion = valueCodecVersion, + base = copiedPresence(base), + mine = copiedPresence(mine), + baseMeta = baseMeta, + ) + + /** + * The in-memory present adoption: validate the acknowledgement and its optional canonical + * target, then `apply -> confirmFresh` at the EFFECTIVE canonical key, then retire — and for a + * redirect, activate the alias inside the same `NonCancellable` accepted-state handoff that + * advances the mutation-owned alias revision. + * + * The authoritative value is rebuilt through the codec's copy boundaries before adoption so a + * server retaining its acknowledged object cannot mutate adopted state or the echo-forward + * base. Adoption failure propagates; the intent stays pending. An alias-protocol violation + * (cross-namespace target, retarget, cycle, or a generation retry acknowledging a different + * canonical target) records one normalized `PROTOCOL` carrier, returns the intent to `READY`, + * performs no adoption, and returns null so the pass halts. + */ + private suspend fun stageLegacyPresentAck( + key: K, + entry: JournalEntry, + idempotencyKey: String, + ack: MutationPresentAck, + ): LegacyPendingPresentAck? = + mutations.withLock { + val source = key.identity() + val admission = + aliasRouter.admit( + source = source, + claimed = ack.canonicalKey?.identity(), + idempotencyKey = idempotencyKey, + createdByClientId = clientId, + createdBySequence = entry.durableClientSequence, + createdAt = wallClock.nowEpochMillis(), + ) + when (admission) { + is AliasAdmission.Rejected -> { + recordNormalizedFailure( + kind = MutationFailureKind.PROTOCOL, + detail = admission.detail, + message = admission.message, + ) + completedAttempts[entry.mutationId] = + (completedAttempts[entry.mutationId] ?: 0) + 1 + phases[entry.mutationId] = MutationExecutionPhase.READY + null + } + is AliasAdmission.Admitted -> { + aliasRouter.commitAdmission(admission) + val targetKey = admission.redirect?.let { checkNotNull(ack.canonicalKey) } ?: key + cacheLiveKey(targetKey.identity(), targetKey) + val pending = + LegacyPendingPresentAck( + sourceKey = key, + targetKey = targetKey, + authoritative = copiedValue(ack.authoritative), + etag = ack.etag, + ) + phases[entry.mutationId] = MutationExecutionPhase.ACKED + legacyPendingPresentAcks[entry.mutationId] = pending + pending + } + } + } + + private suspend fun resumeLegacyPresentAck( + entry: JournalEntry, + pending: LegacyPendingPresentAck, + ): PresentAdoption { + try { + handle.apply(pending.targetKey, pending.authoritative) + handle.confirmFresh(pending.targetKey, pending.etag) + if (pending.sourceKey.identity() == pending.targetKey.identity()) { + retire(pending.sourceKey, entry) + } else { + retireAndActivateAlias( + sourceKey = pending.sourceKey, + targetKey = pending.targetKey, + entry = entry, + ) + } + } catch (failure: Throwable) { + // The codec-less path retries by retransmitting the same generation. Retain + // the alias receipt, but discard only this in-process adoption cursor so the next + // foreground pass reaches the server and revalidates the acknowledged target. + legacyPendingPresentAcks.remove(entry.mutationId) + throw failure + } + legacyPendingPresentAcks.remove(entry.mutationId) + return PresentAdoption( + adopted = pending.authoritative, + effectiveKey = pending.targetKey, + ) + } + + /** + * The absent adoption: the acknowledged state is recorded, confirmed absence is adopted + * through the bound `Store.clear` door, and only then does the intent retire and signal. + * The sealed [MutationAbsentAck] carries no canonical key, so rekey-on-deletion is + * unrepresentable. + */ + private suspend fun adoptAbsent( + key: K, + entry: JournalEntry, + ) { + phases[entry.mutationId] = MutationExecutionPhase.ACKED + absentAdoption(key) + retire(key, entry) + } + + private suspend fun retire( + key: K, + entry: JournalEntry, + ) { + journal.retire(key.identity(), entry.mutationId) + phases.remove(entry.mutationId) + completedAttempts.remove(entry.mutationId) + recordRetiredSequence(entry.clientSequence) + signalChange(key) + } + + /** + * One accepted-state handoff: inside a single `NonCancellable` block the intent retires, the + * redirect activates, the source's queued siblings re-home to the canonical identity (merged + * by durable client sequence — selection is sequence-ordered, so physical order is not + * authority), the canonical `K` is cached, and the mutation-owned alias revision and + * resolution pulses advance synchronously BEFORE the compatibility `Overlay.changes` signals + * are emitted. Caller cancellation after the in-memory commit therefore cannot strand a live + * provisional stream. + */ + private suspend fun retireAndActivateAlias( + sourceKey: K, + targetKey: K, + entry: JournalEntry, + ) { + val source = sourceKey.identity() + val target = targetKey.identity() + withContext(NonCancellable) { + mutations.withLock { + val activation = aliasRouter.activationRecord(source, wallClock.nowEpochMillis()) + activation?.let { record -> aliasRouter.persistActivation(record) } + journal.publishAliasRetirement( + source = source, + terminalTarget = target, + retiredMutationId = entry.mutationId, + ) + cacheLiveKey(target, targetKey) + phases.remove(entry.mutationId) + completedAttempts.remove(entry.mutationId) + recordRetiredSequence(entry.clientSequence) + // The synchronous stateful revision handoff: no cancellable suspension may + // separate the in-memory commit above from these advances. + aliasRevisionSignal(source).update { revision -> revision + 1L } + bumpResolutionPulse(source) + bumpResolutionPulse(target) + } + signalSink.emit(sourceKey) + signalSink.emit(targetKey) + } + } + + /** Advances the in-memory contiguous retired prefix; gaps hold the high-water. */ + private suspend fun recordRetiredSequence(clientSequence: Long) { + retirementPass.withLock { + if (clientSequence <= retiredThroughSequence) return@withLock + retiredSequences += clientSequence + while (retiredSequences.remove(retiredThroughSequence + 1)) { + retiredThroughSequence += 1 + } + } + } + + /** + * Captures the intent's normalized invalidation-effect snapshot before its first push. A + * throwing `stales` function is contained exactly like a throwing projector — ephemeral + * poison, no transport — and halts this key's pass. + */ + private fun captureEffects( + key: K, + entry: JournalEntry, + ): Boolean { + val effects = evaluateEffects(key, entry) ?: return false + effectSnapshots[entry.mutationId] = effects + return true + } + + private fun evaluateEffects( + key: K, + entry: JournalEntry, + ): List? { + val registration = registry.registrations[entry.mutatorId] ?: return null + return try { + normalizedMutationEffects(registration.stales(key, entry.args)) + } catch (failure: Throwable) { + poisonSink.tryEmit( + PoisonedIntent( + mutationId = entry.mutationId, + mutatorId = entry.mutatorId, + failure = failure, + ), + ) + null + } + } + + /** The captured in-memory effect snapshot; the durable rows are [durableEffectsSnapshot]. */ + internal fun capturedEffectsSnapshot(mutationId: String): List? = + effectSnapshots[mutationId] + + internal fun durableEffectsSnapshot(mutationId: String): List = + durableEffectRows[mutationId].orEmpty() + + internal fun tombstoneSnapshot(identity: KeyIdentity): List = + hydratedTombstones.filter { row -> + row.namespace == identity.namespace && row.canonicalId == identity.canonicalId + } + + private fun copiedPresence(presence: MutationPresence): MutationPresence = + when (presence) { + is MutationPresence.Present -> MutationPresence.Present(copiedValue(presence.value)) + MutationPresence.Absent -> MutationPresence.Absent + } + + /** + * The library-side defensive-copy boundary for values crossing the transport seam: encode + * then decode through the retained codec so the consumer-visible object is never the + * engine's stored/projected instance. Direct engine constructions without a codec pass the + * value through — the factory always retains one. + */ + private fun copiedValue(value: V): V { + val codec = valueCodec ?: return value + return codec.decodeCopied(valueCodecVersion, codec.encodeCopied(value)) + } + + /** A library-owned immutable snapshot of captured metadata fields. */ + private fun snapshotMeta(meta: StoreMeta?): StoreMeta? = + meta?.let { captured -> + CapturedMetaSnapshot( + writtenAtEpochMillis = captured.writtenAtEpochMillis, + etag = captured.etag, + ) + } + + private fun presenceOf(value: V?): MutationPresence = + if (value == null) MutationPresence.Absent else MutationPresence.Present(value) + + private suspend fun signalChange(key: K) { + // Once a journal transition completes, caller cancellation cannot discard its accepted + // key-change handoff and strand a different key behind SharedFlow replay backpressure. + withContext(NonCancellable) { + signalSink.emit(key) + } + } +} + +private class CapturedBase( + val presence: MutationPresence, + val meta: StoreMeta?, +) + +private class CapturedMetaSnapshot( + override val writtenAtEpochMillis: Long, + override val etag: String?, +) : StoreMeta + +private class ProjectionOutcome( + val value: MutationPresence, + val advanced: Boolean, + val failure: Throwable?, +) + +private enum class DurablePreparationOutcome { + PREPARED, + BLOCKED, + PARKED, +} + +private sealed interface PreconditionSelection { + class Selected( + val meta: StoreMeta?, + ) : PreconditionSelection + + class Failed( + val failure: Throwable, + ) : PreconditionSelection +} + +private enum class DurableConflictOutcome { + RETRY_PREPARED, + PARKED, + RETIRED, +} + +private enum class DurableConflictReceiptOutcome { + REFRESH_REQUIRED, + PARKED, +} + +private enum class DurableAckReceiptOutcome { + ACKED, + PARKED, + HALTED, +} + +private sealed interface DurableAckReceiptDecision { + val idempotencyKey: String + val pinnedTarget: KeyIdentity? + + class Commit( + val aliasAdmission: AliasAdmission.Admitted?, + override val idempotencyKey: String, + override val pinnedTarget: KeyIdentity, + ) : DurableAckReceiptDecision + + class Reject( + val source: KeyIdentity, + val rejection: AliasAdmission.Rejected, + override val idempotencyKey: String, + override val pinnedTarget: KeyIdentity?, + ) : DurableAckReceiptDecision +} + +private class DurableConflictCommit( + val attempt: MutationAttemptRecord, + val execution: MutationExecutionRecord, + val failure: MutationFailureRecord?, +) + +private class DurableServerWinsCommit( + val execution: MutationExecutionRecord, + val effects: List, + val skippedEffects: List, + val retiredThroughSequence: Long, + val serverConfirmedRetiredThroughSequence: Long, +) + +/** Private postcommit carrier keeping durable truth ahead of every runtime publication. */ +private class DurableRetirementCommit( + val execution: MutationExecutionRecord, + val retiredThroughSequence: Long, + val serverConfirmedRetiredThroughSequence: Long, + val tombstoneReplacements: List, + val aliasPublication: DurableAliasPublication?, +) + +private class DurableAliasPublication( + val activation: MutationKeyAliasRecord, + val terminalTarget: KeyIdentity, +) { + val source: KeyIdentity = + KeyIdentity( + namespace = activation.sourceNamespace, + canonicalId = activation.sourceCanonicalId, + ) +} + +private class PreAckParkCandidate( + val kind: MutationFailureKind, + val detail: String, + val message: String, +) + +private class DurableParkCommit( + val failure: MutationFailureRecord, + val execution: MutationExecutionRecord, +) + +/** Signal-only key: core matches Overlay.changes solely by namespace/canonical identity. */ +private class ProjectionRevisionKey( + identity: KeyIdentity, +) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace(identity.namespace) + private val canonicalId: String = identity.canonicalId + + override fun canonicalId(): String = canonicalId +} + +private object UnavailableMutationArgs + +private fun MutationFailureRecord.toPublicFailure(): MutationFailure = + MutationFailure( + kind = kind, + detail = detail, + message = message, + occurredAtEpochMillis = occurredAt, + ) + +private fun StoredExecutionPhase.toEnginePhase(): MutationExecutionPhase = + when (this) { + StoredExecutionPhase.UNPREPARED -> MutationExecutionPhase.UNPREPARED + StoredExecutionPhase.READY -> MutationExecutionPhase.READY + StoredExecutionPhase.INFLIGHT -> MutationExecutionPhase.INFLIGHT + StoredExecutionPhase.REFRESH_REQUIRED -> MutationExecutionPhase.REFRESH_REQUIRED + StoredExecutionPhase.ACKED -> MutationExecutionPhase.ACKED + StoredExecutionPhase.EFFECTS_PENDING -> MutationExecutionPhase.EFFECTS_PENDING + StoredExecutionPhase.PARKED -> MutationExecutionPhase.PARKED + StoredExecutionPhase.RETIRED -> MutationExecutionPhase.RETIRED + } + +/** C8's complete derived durable owner predicate; no separate owner row is persisted. */ +private fun MutationExecutionRecord.ownsNamespaceAuthority(): Boolean = + when (phase) { + StoredExecutionPhase.INFLIGHT, + StoredExecutionPhase.REFRESH_REQUIRED, + StoredExecutionPhase.ACKED, + StoredExecutionPhase.EFFECTS_PENDING, + -> true + + StoredExecutionPhase.READY -> attempt > 0 || currentGeneration > 1 + + StoredExecutionPhase.UNPREPARED, + StoredExecutionPhase.PARKED, + StoredExecutionPhase.RETIRED, + -> false + } + +private fun MutationExecutionPhase.permitsPreAckParking(kind: MutationFailureKind): Boolean = + when (this) { + MutationExecutionPhase.UNPREPARED, + MutationExecutionPhase.READY, + -> + kind == MutationFailureKind.IDENTITY || + kind == MutationFailureKind.CODEC || + kind == MutationFailureKind.PROJECTION + + MutationExecutionPhase.INFLIGHT, + MutationExecutionPhase.REFRESH_REQUIRED, + -> kind == MutationFailureKind.IDENTITY || kind == MutationFailureKind.CODEC + + MutationExecutionPhase.ACKED, + MutationExecutionPhase.EFFECTS_PENDING, + MutationExecutionPhase.PARKED, + MutationExecutionPhase.RETIRED, + -> false + } + +private fun MutationExecutionRecord.copyExecution( + phase: StoredExecutionPhase = this.phase, + currentGeneration: Int = this.currentGeneration, + attempt: Int = this.attempt, + lastAttemptAt: Long? = this.lastAttemptAt, + activeFailureId: Long? = this.activeFailureId, + retiredAt: Long? = this.retiredAt, +): MutationExecutionRecord = + MutationExecutionRecord( + clientId = clientId, + clientSequence = clientSequence, + phase = phase, + currentGeneration = currentGeneration, + attempt = attempt, + lastAttemptAt = lastAttemptAt, + activeFailureId = activeFailureId, + retiredAt = retiredAt, + ) + +private fun MutationAttemptRecord.withConflictReceipt( + conflictMetaPresent: Boolean, + conflictWrittenAt: Long?, + conflictEtag: String?, + conflictReceivedAt: Long, +): MutationAttemptRecord = + MutationAttemptRecord( + clientId = clientId, + clientSequence = clientSequence, + generation = generation, + effectiveNamespace = effectiveNamespace, + effectiveCanonicalId = effectiveCanonicalId, + valueCodecVersion = valueCodecVersion, + basePresence = basePresence, + baseBlob = baseBlob, + minePresence = minePresence, + mineBlob = mineBlob, + preconditionMetaPresent = preconditionMetaPresent, + preconditionWrittenAt = preconditionWrittenAt, + preconditionEtag = preconditionEtag, + advertisedRetiredThroughSequence = advertisedRetiredThroughSequence, + generationIdempotencyKey = generationIdempotencyKey, + preparedAt = preparedAt, + conflictMetaPresent = conflictMetaPresent, + conflictWrittenAt = conflictWrittenAt, + conflictEtag = conflictEtag, + conflictReceivedAt = conflictReceivedAt, + ) + +private fun StoredEffectRecord.skippedAt(completedAt: Long): StoredEffectRecord = + StoredEffectRecord( + clientId = clientId, + clientSequence = clientSequence, + effectIndex = effectIndex, + kind = kind, + namespace = namespace, + canonicalId = canonicalId, + createdAt = createdAt, + disposition = MutationEffectDisposition.SKIPPED, + completedAt = completedAt, + ) + +private fun StoredEffectRecord.appliedAt(completedAt: Long): StoredEffectRecord = + StoredEffectRecord( + clientId = clientId, + clientSequence = clientSequence, + effectIndex = effectIndex, + kind = kind, + namespace = namespace, + canonicalId = canonicalId, + createdAt = createdAt, + disposition = MutationEffectDisposition.APPLIED, + completedAt = completedAt, + ) + +private fun MutationKeyTombstoneRecord.activatedAt( + activatedAt: Long, +): MutationKeyTombstoneRecord = + MutationKeyTombstoneRecord( + namespace = namespace, + canonicalId = canonicalId, + createdByClientId = createdByClientId, + createdBySequence = createdBySequence, + state = MutationTombstoneState.ACTIVE, + createdAt = createdAt, + activatedAt = activatedAt, + supersededByClientId = null, + supersededBySequence = null, + supersededAt = null, + ) + +private fun MutationKeyTombstoneRecord.supersededBy( + successorClientId: String, + successorSequence: Long, + supersededAt: Long, +): MutationKeyTombstoneRecord = + MutationKeyTombstoneRecord( + namespace = namespace, + canonicalId = canonicalId, + createdByClientId = createdByClientId, + createdBySequence = createdBySequence, + state = MutationTombstoneState.SUPERSEDED, + createdAt = createdAt, + activatedAt = checkNotNull(activatedAt), + supersededByClientId = successorClientId, + supersededBySequence = successorSequence, + supersededAt = supersededAt, + ) + +private fun MutationKeyTombstoneRecord.identity(): KeyIdentity = + KeyIdentity(namespace, canonicalId) + +private fun MutationKeyTombstoneRecord.sameGenerationAs( + other: MutationKeyTombstoneRecord, +): Boolean = + namespace == other.namespace && + canonicalId == other.canonicalId && + createdByClientId == other.createdByClientId && + createdBySequence == other.createdBySequence + +private fun MutationPresence.toPresenceState(): MutationPresenceState = + when (this) { + is MutationPresence.Present -> MutationPresenceState.PRESENT + MutationPresence.Absent -> MutationPresenceState.ABSENT + } + +private fun MutationPresence.encodePresentOrNull( + codec: MutationCodec, +): ByteArray? = + when (this) { + is MutationPresence.Present -> codec.encodeCopied(value) + MutationPresence.Absent -> null + } + +private fun MutationAttemptRecord.decodeBase( + codec: MutationCodec, +): MutationPresence = + when (basePresence) { + MutationPresenceState.PRESENT -> + MutationPresence.Present(codec.decodeCopied(valueCodecVersion, checkNotNull(baseBlob))) + MutationPresenceState.ABSENT -> MutationPresence.Absent + } + +private fun MutationAttemptRecord.decodeMine( + codec: MutationCodec, +): MutationPresence = + when (minePresence) { + MutationPresenceState.PRESENT -> + MutationPresence.Present(codec.decodeCopied(valueCodecVersion, checkNotNull(mineBlob))) + MutationPresenceState.ABSENT -> MutationPresence.Absent + } + +private fun MutationAttemptRecord.preconditionMetaOrNull(): StoreMeta? = + if (preconditionMetaPresent) { + CapturedMetaSnapshot( + writtenAtEpochMillis = checkNotNull(preconditionWrittenAt), + etag = preconditionEtag, + ) + } else { + null + } + +private fun MutationAttemptRecord.conflictMetaOrNull(): StoreMeta? = + if (conflictMetaPresent == true) { + CapturedMetaSnapshot( + writtenAtEpochMillis = checkNotNull(conflictWrittenAt), + etag = conflictEtag, + ) + } else { + null + } + +private fun KeyIdentity.toEventIdentity(): MutationKeyIdentity = + MutationKeyIdentity(namespace, canonicalId) + +private fun MutationAttemptRecord.toEventIdentity(): MutationKeyIdentity = + MutationKeyIdentity(effectiveNamespace, effectiveCanonicalId) + +/** One completed present adoption: the adopted value and the effective (possibly rekeyed) key. */ +private class PresentAdoption( + val adopted: V, + val effectiveKey: K, +) + +/** Copy-on-write map used by cross-identity drain bookkeeping in common code. */ +private class AtomicMutableMap { + private val state = MutableStateFlow>(emptyMap()) + + operator fun get(key: K): V? = state.value[key] + + operator fun set( + key: K, + value: V, + ) { + state.update { current -> current + (key to value) } + } + + fun remove(key: K): V? { + val previous = state.value[key] + state.update { current -> current - key } + return previous + } + + fun clear() { + state.value = emptyMap() + } + + val values: Collection + get() = state.value.values +} + +/** Copy-on-write list used by cross-identity drain bookkeeping in common code. */ +private class AtomicMutableList { + private val state = MutableStateFlow>(emptyList()) + + operator fun plusAssign(value: T) { + state.update { current -> current + value } + } + + fun addAll(values: Iterable) { + state.update { current -> current + values } + } + + fun clear() { + state.value = emptyList() + } + + fun replaceAllBy( + replacements: List, + sameRecord: (T, T) -> Boolean, + ) { + state.update { current -> + current.filterNot { existing -> + replacements.any { replacement -> sameRecord(existing, replacement) } + } + replacements + } + } + + fun toList(): List = state.value + + fun filter(predicate: (T) -> Boolean): List = state.value.filter(predicate) +} + +/** Copy-on-write set used by cross-identity drain bookkeeping in common code. */ +private class AtomicMutableSet { + private val state = MutableStateFlow>(emptySet()) + + operator fun contains(value: T): Boolean = value in state.value + + operator fun plusAssign(value: T) { + state.update { current -> current + value } + } + + fun remove(value: T): Boolean { + val contained = value in state.value + state.update { current -> current - value } + return contained + } + + fun clear() { + state.value = emptySet() + } + + fun toMutableSet(): MutableSet = state.value.toMutableSet() +} + +private data class RetirementState( + val retiredThroughSequence: Long = 0L, + val serverConfirmedRetiredThroughSequence: Long = 0L, +) + +private class LegacyPendingPresentAck( + val sourceKey: K, + val targetKey: K, + val authoritative: V, + val etag: String?, +) + +private class DrainRehome( + val processingKey: K, + val targetIdentity: KeyIdentity, + val entry: JournalEntry, + val sequenceBound: Long, +) + +private class DrainHead( + val entry: JournalEntry, + val phase: MutationExecutionPhase, +) + +/** Private control-flow marker; keyed callers always receive [exposed] verbatim. */ +private class RetryablePostAckFailure( + val exposed: Throwable, + val mutationId: String, + val retainedPhase: StoredExecutionPhase, +) : RuntimeException(exposed.message, exposed) + +/** Rolls back a storage callback while preserving a raw durability/programming invariant. */ +private class NonRetryablePostAckInvariantFailure( + val exposed: Throwable, +) : RuntimeException(exposed.message, exposed) + +private class DurableNamespaceOwner( + val clientSequence: Long, + val identity: KeyIdentity, +) + +/** + * One identity-aware, ref-counted process lease per namespace for this engine's fixed client id. + * Equal-effective contenders reserve and wait; distinct contenders return immediately. Durable + * rows decide authority, and cleanup remains cancellation-safe through the complete pass handoff. + */ +private class NamespaceDrainScheduler { + private val guard = Mutex() + private val slots = mutableMapOf() + + suspend fun tryWithNamespace( + requestedIdentity: KeyIdentity, + sameEffectiveIdentity: (KeyIdentity, KeyIdentity) -> Boolean, + block: suspend () -> Unit, + ): Boolean { + val namespace = requestedIdentity.namespace + var acquired = false + val slot = + guard.withLock { + val existing = slots[namespace] + when { + existing == null -> { + NamespaceDrainSlot(requestedIdentity).also { created -> + slots[namespace] = created + acquired = true + } + } + + sameEffectiveIdentity(existing.leasedIdentity, requestedIdentity) -> { + existing.users += 1 + existing + } + + else -> null + } + } ?: return false + try { + if (!acquired) { + slot.mutex.lock() + acquired = true + } + block() + return true + } finally { + withContext(NonCancellable) { + if (acquired) { + slot.mutex.unlock() + } + guard.withLock { + check(slot.users > 0) + slot.users -= 1 + if (slot.users == 0) { + check(slots[namespace] === slot) + slots.remove(namespace) + } + } + } + } + } +} + +private class NamespaceDrainSlot( + val leasedIdentity: KeyIdentity, + val mutex: Mutex = Mutex(locked = true), + var users: Int = 1, +) + +/** + * Ref-counted per-effective-identity mutex registry. A slot remains reachable while either a + * holder or waiter references it and is removed immediately after its final user exits. + */ +private class IdentityDrainScheduler { + private val guard = Mutex() + private val slots = mutableMapOf() + + suspend fun withIdentity( + identity: KeyIdentity, + block: suspend () -> T, + ): T = withIdentities(listOf(identity), block) + + suspend fun withIdentities( + identities: Collection, + block: suspend () -> T, + ): T { + val orderedIdentities = + identities + .distinct() + .sortedWith(compareBy({ it.namespace }, { it.canonicalId })) + require(orderedIdentities.isNotEmpty()) + val reserved = + guard.withLock { + orderedIdentities.map { identity -> + identity to + slots.getOrPut(identity) { IdentityDrainSlot() }.also { current -> + current.users += 1 + } + } + } + val acquired = mutableListOf>() + try { + for (reservation in reserved) { + reservation.second.mutex.lock() + acquired += reservation + } + return block() + } finally { + withContext(NonCancellable) { + for ((_, slot) in acquired.asReversed()) { + slot.mutex.unlock() + } + guard.withLock { + for ((identity, slot) in reserved) { + slot.users -= 1 + check(slot.users >= 0) + if (slot.users == 0) { + check(slots[identity] === slot) + slots.remove(identity) + } + } + } + } + } + } +} + +private class IdentityDrainSlot( + val mutex: Mutex = Mutex(), + var users: Int = 0, +) diff --git a/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEvents.kt b/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEvents.kt new file mode 100644 index 000000000..29cef5499 --- /dev/null +++ b/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEvents.kt @@ -0,0 +1,313 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.mutations + +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreMeta + +/** + * Read-only, in-process advisory mutation telemetry. + * + * Delivery is best-effort: replay `0`, extra buffer capacity `64`, oldest dropped on overflow, + * emitted with non-blocking `tryEmit` only. Events may drop under pressure, a new collector + * receives no history, and restart replays no completed events. This flow is never a drain, + * acknowledgement, retry, or settlement protocol; durable truth remains `pending`, + * `pendingWrites`, `deadLetters`, and the journal. No event carries a raw `Throwable` or + * `StoreError`. + */ +@ExperimentalStoreApi +public sealed interface MutationEvent { + /** The observation time in Unix epoch milliseconds. */ + @ExperimentalStoreApi + public val occurredAtEpochMillis: Long +} + +/** + * An advisory event scoped to one mutation intent. + * + * [identity] is the normalized terminal pair stored when enqueue commits until an attempt + * exists, then the immutable push identity for that generation. An acknowledgement that + * introduces a canonical target does not rewrite an already-sent generation's event identity. + */ +@ExperimentalStoreApi +public sealed interface MutationIntentEvent : MutationEvent { + /** The opaque public id assigned at enqueue. */ + @ExperimentalStoreApi + public val mutationId: String + + /** The effective durable identity pair for this event's generation. */ + @ExperimentalStoreApi + public val identity: MutationKeyIdentity +} + +/** One intent durably entered the journal. */ +@ExperimentalStoreApi +public class MutationEnqueued internal constructor( + @ExperimentalStoreApi + public override val mutationId: String, + @ExperimentalStoreApi + public override val identity: MutationKeyIdentity, + @ExperimentalStoreApi + public override val occurredAtEpochMillis: Long, + + /** The durable per-client sequence allocated at enqueue. */ + @ExperimentalStoreApi + public val clientSequence: Long, + + /** The registered mutator storage identity. */ + @ExperimentalStoreApi + public val mutatorId: String, +) : MutationIntentEvent + +/** + * One transport invocation began. `attempt` is the one-based transport invocation ordinal, + * attempted after the `INFLIGHT` transition becomes durable but before transport. + */ +@ExperimentalStoreApi +public class MutationAttempted internal constructor( + @ExperimentalStoreApi + public override val mutationId: String, + @ExperimentalStoreApi + public override val identity: MutationKeyIdentity, + @ExperimentalStoreApi + public override val occurredAtEpochMillis: Long, + + /** The semantic generation being transmitted. */ + @ExperimentalStoreApi + public val generation: Int, + + /** The one-based transport invocation ordinal for `generation`. */ + @ExperimentalStoreApi + public val attempt: Int, +) : MutationIntentEvent + +/** The backend reported a precondition conflict for one generation. */ +@ExperimentalStoreApi +public class MutationConflictObserved internal constructor( + @ExperimentalStoreApi + public override val mutationId: String, + @ExperimentalStoreApi + public override val identity: MutationKeyIdentity, + @ExperimentalStoreApi + public override val occurredAtEpochMillis: Long, + + /** The conflicted generation. */ + @ExperimentalStoreApi + public val generation: Int, + + /** The server-reported metadata receipt, when one exists. */ + @ExperimentalStoreApi + public val serverMeta: StoreMeta?, +) : MutationIntentEvent + +/** A successful acknowledgement became durable. */ +@ExperimentalStoreApi +public class MutationAcknowledged internal constructor( + @ExperimentalStoreApi + public override val mutationId: String, + @ExperimentalStoreApi + public override val identity: MutationKeyIdentity, + @ExperimentalStoreApi + public override val occurredAtEpochMillis: Long, + + /** The acknowledged generation. */ + @ExperimentalStoreApi + public val generation: Int, + + /** The authoritative presence the backend confirmed. */ + @ExperimentalStoreApi + public val presence: MutationPresenceState, +) : MutationIntentEvent + +/** A durable acknowledgement was adopted into the Store. */ +@ExperimentalStoreApi +public class MutationAdopted internal constructor( + @ExperimentalStoreApi + public override val mutationId: String, + @ExperimentalStoreApi + public override val identity: MutationKeyIdentity, + @ExperimentalStoreApi + public override val occurredAtEpochMillis: Long, + + /** The adopted generation. */ + @ExperimentalStoreApi + public val generation: Int, + + /** The adopted authoritative presence. */ + @ExperimentalStoreApi + public val presence: MutationPresenceState, +) : MutationIntentEvent + +/** One durable invalidation effect reached its terminal `APPLIED` disposition. */ +@ExperimentalStoreApi +public class MutationEffectApplied internal constructor( + @ExperimentalStoreApi + public override val mutationId: String, + @ExperimentalStoreApi + public override val identity: MutationKeyIdentity, + @ExperimentalStoreApi + public override val occurredAtEpochMillis: Long, + + /** The generation whose effect completed. */ + @ExperimentalStoreApi + public val generation: Int, + + /** The stable normalized index of the completed effect record. */ + @ExperimentalStoreApi + public val effectIndex: Int, +) : MutationIntentEvent + +/** One durable invalidation effect was terminally `SKIPPED` by conflict server-wins. */ +@ExperimentalStoreApi +public class MutationEffectSkipped internal constructor( + @ExperimentalStoreApi + public override val mutationId: String, + @ExperimentalStoreApi + public override val identity: MutationKeyIdentity, + @ExperimentalStoreApi + public override val occurredAtEpochMillis: Long, + + /** The generation whose effect was skipped. */ + @ExperimentalStoreApi + public val generation: Int, + + /** The stable normalized index of the skipped effect record. */ + @ExperimentalStoreApi + public val effectIndex: Int, +) : MutationIntentEvent + +/** An active retryable failure that did not park. */ +@ExperimentalStoreApi +public class MutationFailed internal constructor( + @ExperimentalStoreApi + public override val mutationId: String, + @ExperimentalStoreApi + public override val identity: MutationKeyIdentity, + @ExperimentalStoreApi + public override val occurredAtEpochMillis: Long, + + /** The generation current when the failure was recorded. */ + @ExperimentalStoreApi + public val generation: Int, + + /** The public active state retained by the failure. */ + @ExperimentalStoreApi + public val state: MutationPendingState, + + /** The normalized failure evidence. */ + @ExperimentalStoreApi + public val failure: MutationFailure, +) : MutationIntentEvent + +/** One intent durably parked and left the executable FIFO. */ +@ExperimentalStoreApi +public class MutationParked internal constructor( + @ExperimentalStoreApi + public override val mutationId: String, + @ExperimentalStoreApi + public override val identity: MutationKeyIdentity, + @ExperimentalStoreApi + public override val occurredAtEpochMillis: Long, + + /** The generation current when the intent parked; 0 before first preparation. */ + @ExperimentalStoreApi + public val generation: Int, + + /** The normalized terminal parked reason. */ + @ExperimentalStoreApi + public val failure: MutationFailure, +) : MutationIntentEvent + +/** One intent durably retired and the contiguous local high-water advanced. */ +@ExperimentalStoreApi +public class MutationRetired internal constructor( + @ExperimentalStoreApi + public override val mutationId: String, + @ExperimentalStoreApi + public override val identity: MutationKeyIdentity, + @ExperimentalStoreApi + public override val occurredAtEpochMillis: Long, + + /** The retired generation. */ + @ExperimentalStoreApi + public val generation: Int, + + /** The contiguous locally retired prefix after this retirement. */ + @ExperimentalStoreApi + public val retiredThroughSequence: Long, +) : MutationIntentEvent + +/** + * A validated and persisted retirement-checkpoint receipt. Client-scoped: checkpoint + * events intentionally do not fabricate a mutation id, key identity, or generation after all + * mutations have retired. + */ +@ExperimentalStoreApi +public class MutationCheckpointConfirmed internal constructor( + @ExperimentalStoreApi + public override val occurredAtEpochMillis: Long, + + /** The stable installation/journal identity whose prefix was confirmed. */ + @ExperimentalStoreApi + public val clientId: String, + + /** The prefix offered by the checkpoint request. */ + @ExperimentalStoreApi + public val requestedThroughSequence: Long, + + /** The persisted server-confirmed prefix. */ + @ExperimentalStoreApi + public val confirmedThroughSequence: Long, +) : MutationEvent + +/** + * A non-cancellation checkpoint transport, protocol, or persistence failure. Client-scoped + * and ephemeral: it creates no intent-owned failure row and invents no retired mutation + * identity. + */ +@ExperimentalStoreApi +public class MutationCheckpointFailed internal constructor( + @ExperimentalStoreApi + public override val occurredAtEpochMillis: Long, + + /** The stable installation/journal identity whose checkpoint failed. */ + @ExperimentalStoreApi + public val clientId: String, + + /** The prefix offered by the failed checkpoint request. */ + @ExperimentalStoreApi + public val requestedThroughSequence: Long, + + /** The normalized ephemeral failure carrier. */ + @ExperimentalStoreApi + public val failure: MutationFailure, +) : MutationEvent + +/** + * The internal advisory event bus backing `MutationStore.events`. + * + * A `MutableSharedFlow` configured with [BufferOverflow] carries the events. Emission is + * [tryEmit]-only, so lifecycle work can never block or suspend on telemetry: replay `0`, extra + * buffer capacity `64`, and [BufferOverflow.DROP_OLDEST] make every emission complete immediately, + * dropping the oldest buffered event under pressure. Dropping an event never changes state; within + * one accepted-state handoff, lifecycle events are attempted only after the corresponding state + * becomes observable. + */ +internal class MutationEventBus { + private val sink = + MutableSharedFlow( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + /** The read-only advisory view republished by the facade. */ + internal val events: SharedFlow = sink.asSharedFlow() + + /** Non-blocking advisory emission; always completes immediately under DROP_OLDEST. */ + internal fun tryEmit(event: MutationEvent): Boolean = sink.tryEmit(event) +} diff --git a/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspection.kt b/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspection.kt new file mode 100644 index 000000000..955139fea --- /dev/null +++ b/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspection.kt @@ -0,0 +1,240 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.mutations + +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi + +/** The stable public presence names used by acknowledgement and adoption reporting. */ +@ExperimentalStoreApi +public enum class MutationPresenceState { + PRESENT, + ABSENT, +} + +/** + * The total public mapping of every nonterminal active execution phase. + * + * `UNPREPARED`/`READY` map to [PENDING], `INFLIGHT` to [INFLIGHT], `REFRESH_REQUIRED` to + * [REFRESHING], `ACKED` to [ADOPTING], and `EFFECTS_PENDING` to [APPLYING_EFFECTS]. Parked + * executions appear only in dead letters; retired executions appear in neither inspection API. + */ +@ExperimentalStoreApi +public enum class MutationPendingState { + PENDING, + INFLIGHT, + REFRESHING, + ADOPTING, + APPLYING_EFFECTS, +} + +/** The broad, append-only classification of a normalized mutation failure. */ +@ExperimentalStoreApi +public enum class MutationFailureKind { + /** The registered [MutationKeyResolver] returned null, threw, or returned a mismatched pair. */ + IDENTITY, + + /** Stored argument or value bytes could not be decoded, or their mutator is not registered. */ + CODEC, + + /** A registered projection function threw before transport. */ + PROJECTION, + + /** The backend broke the acknowledgement, alias, or retirement-checkpoint protocol. */ + PROTOCOL, + + /** A precondition conflict ended the intent: the policy threw, or the repeat bound was hit. */ + CONFLICT, + + /** [MutationServer.push] or [MutationServer.retire] threw a non-cancellation failure. */ + TRANSPORT, + + /** Writing a durable acknowledgement into the Store failed. */ + ADOPTION, + + /** Applying a durable invalidation effect to its target failed. */ + EFFECT, + + /** A journal-storage operation failed. */ + PERSISTENCE, +} + +/** + * A normalized, restart-safe failure record. + * + * A raw `StoreError` or `Throwable` is never persisted and never carried here. Sanitization + * contract: `detail` is at most 128 UTF-8 bytes and `message` at most 1,024 UTF-8 bytes, each + * truncated at a code-point boundary after control-character and stack-trace sanitization. The + * library alone constructs failures. + */ +@ExperimentalStoreApi +public class MutationFailure internal constructor( + /** The broad append-only failure classification. */ + @ExperimentalStoreApi + public val kind: MutationFailureKind, + + /** Sanitized machine-readable detail; at most 128 UTF-8 bytes. */ + @ExperimentalStoreApi + public val detail: String, + + /** Sanitized human-readable diagnostic; at most 1,024 UTF-8 bytes. */ + @ExperimentalStoreApi + public val message: String, + + /** The failure occurrence time in Unix epoch milliseconds. */ + @ExperimentalStoreApi + public val occurredAtEpochMillis: Long, +) + +internal const val MUTATION_FAILURE_DETAIL_MAX_UTF8_BYTES: Int = 128 +internal const val MUTATION_FAILURE_MESSAGE_MAX_UTF8_BYTES: Int = 1024 + +/** + * The library-side [MutationFailure] factory enforcing the sanitization contract: stack-trace + * lines are dropped (first line only), remaining ISO control characters are removed, and the + * result is truncated to the UTF-8 byte budget at a code-point boundary. + */ +internal fun sanitizedMutationFailure( + kind: MutationFailureKind, + detail: String, + message: String, + occurredAtEpochMillis: Long, +): MutationFailure = + MutationFailure( + kind = kind, + detail = sanitizeForFailure(detail, MUTATION_FAILURE_DETAIL_MAX_UTF8_BYTES), + message = sanitizeForFailure(message, MUTATION_FAILURE_MESSAGE_MAX_UTF8_BYTES), + occurredAtEpochMillis = occurredAtEpochMillis, + ) + +private fun sanitizeForFailure( + raw: String, + maxUtf8Bytes: Int, +): String { + val firstLine = raw.substringBefore('\n').substringBefore('\r') + val withoutControls = firstLine.filterNot(Char::isISOControl) + return truncateUtf8AtCodePointBoundary(withoutControls, maxUtf8Bytes) +} + +private fun truncateUtf8AtCodePointBoundary( + value: String, + maxUtf8Bytes: Int, +): String { + var usedBytes = 0 + var index = 0 + while (index < value.length) { + val character = value[index] + val isSurrogatePair = + character.isHighSurrogate() && + index + 1 < value.length && + value[index + 1].isLowSurrogate() + val codePointBytes = + when { + isSurrogatePair -> 4 + character.code < 0x80 -> 1 + character.code < 0x800 -> 2 + else -> 3 + } + if (usedBytes + codePointBytes > maxUtf8Bytes) break + usedBytes += codePointBytes + index += if (isSurrogatePair) 2 else 1 + } + return value.substring(0, index) +} + +/** + * A truthful snapshot of one nonterminal active intent. + * + * Snapshots are durable-identity carriers: they expose the identity pair rather than a + * reconstructed `K`, so inspection needs no resolver. + */ +@ExperimentalStoreApi +public class PendingIntent internal constructor( + /** Verbatim effective namespace of the intent. */ + @ExperimentalStoreApi + public val namespace: String, + + /** Verbatim effective canonical id of the intent. */ + @ExperimentalStoreApi + public val canonicalId: String, + + /** The opaque public id assigned at enqueue. */ + @ExperimentalStoreApi + public val mutationId: String, + + /** The registered mutator storage identity. */ + @ExperimentalStoreApi + public val mutatorId: String, + + /** The public mapping of the current active execution phase. */ + @ExperimentalStoreApi + public val state: MutationPendingState, + + /** Completed network attempts for the current generation. */ + @ExperimentalStoreApi + public val attempt: Int, + + /** The durable enqueue time in Unix epoch milliseconds. */ + @ExperimentalStoreApi + public val createdAtEpochMillis: Long, +) + +/** + * A durably parked intent. Dead letters contain only parked entries; parking is legal only + * before a successful acknowledgement is durably recorded, and a parked sequence never re-enters + * the executable FIFO. + */ +@ExperimentalStoreApi +public class DeadLetter internal constructor( + /** Verbatim effective namespace of the parked intent. */ + @ExperimentalStoreApi + public val namespace: String, + + /** Verbatim effective canonical id of the parked intent. */ + @ExperimentalStoreApi + public val canonicalId: String, + + /** The opaque public id assigned at enqueue. */ + @ExperimentalStoreApi + public val mutationId: String, + + /** The registered mutator storage identity. */ + @ExperimentalStoreApi + public val mutatorId: String, + + /** The generation current when the intent parked; 0 before first preparation. */ + @ExperimentalStoreApi + public val generation: Int, + + /** Completed network attempts when the intent parked. */ + @ExperimentalStoreApi + public val attempts: Int, + + /** The normalized terminal parked reason. */ + @ExperimentalStoreApi + public val failure: MutationFailure, + + /** The park commit time in Unix epoch milliseconds. */ + @ExperimentalStoreApi + public val parkedAtEpochMillis: Long, +) + +/** + * An ephemeral projection-failure report carrying the exact local `Throwable`. + * + * The same throw parks the row with a normalized durable `PROJECTION` failure; the throwable + * itself never crosses restart. + */ +@ExperimentalStoreApi +public class PoisonedIntent internal constructor( + /** The opaque public id assigned at enqueue. */ + @ExperimentalStoreApi + public val mutationId: String, + + /** The identifier of the registered projection that threw. */ + @ExperimentalStoreApi + public val mutatorId: String, + + /** The exact failure thrown by the projection; in-process only, never durable. */ + @ExperimentalStoreApi + public val failure: Throwable, +) diff --git a/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt b/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt new file mode 100644 index 000000000..d4bdf04f4 --- /dev/null +++ b/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt @@ -0,0 +1,751 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, +) + +package org.mobilenativefoundation.store6.mutations + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.mutations.storage.InMemoryMutationJournalStorage +import org.mobilenativefoundation.store6.mutations.storage.MutationAliasState +import org.mobilenativefoundation.store6.mutations.storage.MutationAckRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationAttemptRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationClientRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationEffectRecord as StoredEffectRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionPhase as StoredExecutionPhase +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationFailureRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationJournalStorage +import org.mobilenativefoundation.store6.mutations.storage.MutationKeyAliasRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationKeyTombstoneRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationIntentRecord + +internal data class KeyIdentity( + val namespace: String, + val canonicalId: String, +) + +internal fun StoreKey.identity(): KeyIdentity = KeyIdentity(namespace.value, canonicalId()) + +/** The in-memory invalidation-effect kind; durable names are `storage.MutationEffectKind`. */ +internal enum class MutationEffectRecordKind { + KEY, + NAMESPACE, +} + +/** + * One normalized in-memory invalidation-effect target: an immutable snapshot captured before the + * intent's first push. + */ +internal data class MutationEffectRecord( + val kind: MutationEffectRecordKind, + val namespace: String, + val canonicalId: String?, +) + +/** + * Copies, normalizes, deduplicates, and sorts a consumer [StaleSet] into immutable effect + * records: every key is normalized to its full identity pair; ordering is namespace effects + * first, then key effects, each sorted by namespace then canonical id. Equal inputs produce + * structurally equal outputs. + */ +internal fun normalizedMutationEffects( + staleSet: StaleSet, +): List { + val namespaceRecords = + staleSet.namespaces + .map { namespace -> + MutationEffectRecord( + kind = MutationEffectRecordKind.NAMESPACE, + namespace = namespace.value, + canonicalId = null, + ) + } + .distinct() + .sortedBy { record -> record.namespace } + val keyRecords = + staleSet.keys + .map { key -> + MutationEffectRecord( + kind = MutationEffectRecordKind.KEY, + namespace = key.namespace.value, + canonicalId = key.canonicalId(), + ) + } + .distinct() + .sortedWith(compareBy({ record -> record.namespace }, { record -> record.canonicalId })) + return namespaceRecords + keyRecords +} + +/** + * The execution phase vocabulary in its in-memory form; the durable stable names are + * `storage.MutationExecutionPhase`. + */ +internal enum class MutationExecutionPhase { + UNPREPARED, + READY, + INFLIGHT, + REFRESH_REQUIRED, + ACKED, + EFFECTS_PENDING, + PARKED, + RETIRED, +} + +/** + * The total public mapping. `PARKED` maps only to `deadLetters()` and `RETIRED` to neither + * inspection API, so both return null here; every nonterminal active phase maps to exactly one + * [MutationPendingState]. + */ +internal fun MutationExecutionPhase.toPendingStateOrNull(): MutationPendingState? = + when (this) { + MutationExecutionPhase.UNPREPARED, + MutationExecutionPhase.READY, + -> MutationPendingState.PENDING + MutationExecutionPhase.INFLIGHT -> MutationPendingState.INFLIGHT + MutationExecutionPhase.REFRESH_REQUIRED -> MutationPendingState.REFRESHING + MutationExecutionPhase.ACKED -> MutationPendingState.ADOPTING + MutationExecutionPhase.EFFECTS_PENDING -> MutationPendingState.APPLYING_EFFECTS + MutationExecutionPhase.PARKED, + MutationExecutionPhase.RETIRED, + -> null + } + +/** + * One journalled intent. [clientSequence] is the durable per-client FIFO/watermark unit and + * [createdAtEpochMillis] the durable enqueue stamp; the engine allocates both at enqueue. + * Defaults exist only for direct journal construction in module tests. + */ +internal class JournalEntry( + val mutationId: String, + val mutatorId: String, + val args: Any, + val clientSequence: Long = 0L, + val createdAtEpochMillis: Long = 0L, + internal val durableClientSequence: Long = clientSequence, +) + +/** One immutable process-cache view: projection membership and alias routing move together. */ +internal data class MutationRuntimeSnapshot( + val entries: Map>> = emptyMap(), + val aliases: Map = emptyMap(), +) + +/** Shared synchronization and publication state for the journal cache and alias router. */ +internal class MutationRuntimeState { + internal val mutex = Mutex() + internal val snapshots = MutableStateFlow(MutationRuntimeSnapshot()) + + internal fun aliasesSnapshot(): Map = snapshots.value.aliases + + internal fun updateAliases( + transform: (Map) -> Map, + ) { + snapshots.update { current -> + current.copy(aliases = transform(current.aliases)) + } + } +} + +internal interface MutationJournal { + suspend fun append( + key: KeyIdentity, + entry: JournalEntry, + ): String + + suspend fun retire( + key: KeyIdentity, + mutationId: String, + ) + + /** Rehomes a pending cache entry after durable alias activation without reinserting intent. */ + suspend fun rehome( + from: KeyIdentity, + to: KeyIdentity, + entry: JournalEntry, + ) { + retire(from, entry.mutationId) + append(to, entry) + } + + fun pendingSnapshot(key: KeyIdentity): List> + + /** + * The durable identities that currently hold pending intents, in first-enqueue order: + * global drain enumerates these and reconstructs each `K` through the resolver; a live key + * map is never this method's substitute. + */ + fun identities(): Set +} + +/** One internally copied view of all nine durable record groups for a single client. */ +internal class DurableJournalSnapshot( + val client: MutationClientRecord?, + val intents: List, + val executions: List, + val attempts: List, + val acks: List, + val failures: List, + val effects: List, + val aliases: List, + val tombstones: List, +) + +/** + * Cache-fronted adapter over the public durable storage seam. + * + * Synchronous overlay reads use the cache; every accepted append first commits the immutable + * intent and its initial execution row to [storage], then publishes the same entry to the cache. + * The engine hydrates this cache from [storage] on first use. + */ +internal open class StorageBackedMutationJournal( + internal val storage: MutationJournalStorage, + private val registrations: Map> = emptyMap(), + private val clientId: String = "client-0", + internal val hydrateOnFirstUse: Boolean = false, +) : MutationJournal { + internal val runtimeState = MutationRuntimeState() + + override suspend fun append( + key: KeyIdentity, + entry: JournalEntry, + ): String = + runtimeState.mutex.withLock { + val registration = registrations[entry.mutatorId] + val argsVersion = registration?.argsVersion ?: 1 + val argsBlob = registration?.encodeArgs(entry.args) ?: ByteArray(0) + // The durable bytes define the accepted intent. Decode that exact snapshot before + // committing so the live cache never retains a caller-owned mutable args object. + val acceptedArgs = registration?.decodeArgs(argsVersion, argsBlob) ?: entry.args + val storedEntry: JournalEntry = + storage.transaction { transaction -> + val currentClient = + transaction.client(clientId) + ?: MutationClientRecord( + recordVersion = 1, + clientId = clientId, + lastAllocatedSequence = 0L, + retiredThroughSequence = 0L, + serverConfirmedRetiredThroughSequence = 0L, + createdAt = entry.createdAtEpochMillis, + ).also(transaction::insertClient) + val sequence = + if (entry.clientSequence > 0L) { + entry.clientSequence + } else { + currentClient.lastAllocatedSequence + 1L + } + require(sequence == currentClient.lastAllocatedSequence + 1L) { + "Mutation sequence $sequence must immediately follow " + + "${currentClient.lastAllocatedSequence}." + } + transaction.advanceClient( + MutationClientRecord( + recordVersion = currentClient.recordVersion, + clientId = clientId, + lastAllocatedSequence = sequence, + retiredThroughSequence = currentClient.retiredThroughSequence, + serverConfirmedRetiredThroughSequence = + currentClient.serverConfirmedRetiredThroughSequence, + createdAt = currentClient.createdAt, + ), + ) + transaction.insertIntent( + recordVersion = 1, + clientId = clientId, + clientSequence = sequence, + mutationId = entry.mutationId, + namespace = key.namespace, + canonicalId = key.canonicalId, + mutatorId = entry.mutatorId, + mutatorVersion = argsVersion, + argsBlob = argsBlob, + idempotencyRoot = "$clientId:$sequence", + createdAt = entry.createdAtEpochMillis, + ) + transaction.insertExecution( + MutationExecutionRecord( + clientId = clientId, + clientSequence = sequence, + phase = StoredExecutionPhase.UNPREPARED, + currentGeneration = 0, + attempt = 0, + lastAttemptAt = null, + activeFailureId = null, + retiredAt = null, + ), + ) + JournalEntry( + mutationId = entry.mutationId, + mutatorId = entry.mutatorId, + args = acceptedArgs, + // Direct internal-shape tests intentionally use sequence zero. Persist a + // legal allocated sequence while retaining their caller-visible cache + // shape; factory-created entries already carry the same positive value. + clientSequence = entry.clientSequence, + createdAtEpochMillis = entry.createdAtEpochMillis, + durableClientSequence = sequence, + ) + } + runtimeState.snapshots.update { current -> + current.copy( + entries = + current.entries + + (key to current.entries[key].orEmpty() + storedEntry), + ) + } + storedEntry.mutationId + } + + override suspend fun retire( + key: KeyIdentity, + mutationId: String, + ) { + runtimeState.mutex.withLock { + runtimeState.snapshots.update { current -> + val remaining = + current.entries[key] + .orEmpty() + .filterNot { it.mutationId == mutationId } + val updatedEntries = + if (remaining.isEmpty()) { + current.entries - key + } else { + current.entries + (key to remaining) + } + current.copy(entries = updatedEntries) + } + } + } + + override suspend fun rehome( + from: KeyIdentity, + to: KeyIdentity, + entry: JournalEntry, + ) { + runtimeState.mutex.withLock { + runtimeState.snapshots.update { current -> + val sourceRemaining = + current.entries[from].orEmpty().filterNot { candidate -> + candidate.mutationId == entry.mutationId + } + val withoutSource = + if (sourceRemaining.isEmpty()) { + current.entries - from + } else { + current.entries + (from to sourceRemaining) + } + current.copy( + entries = withoutSource + (to to withoutSource[to].orEmpty() + entry), + ) + } + } + } + + override fun pendingSnapshot(key: KeyIdentity): List> = + runtimeState.snapshots.value.entries[key].orEmpty() + + override fun identities(): Set = runtimeState.snapshots.value.entries.keys + + /** One immutable cache capture for projection and inspection paths that must not split alias and entry reads. */ + internal fun runtimeSnapshot(): MutationRuntimeSnapshot = runtimeState.snapshots.value + + /** Reads all nine record groups in one storage transaction; no consumer code runs inside. */ + internal suspend fun readDurableSnapshot(): DurableJournalSnapshot = + storage.transaction { transaction -> + DurableJournalSnapshot( + client = transaction.client(clientId), + intents = transaction.intents(clientId), + executions = transaction.executions(clientId), + attempts = transaction.attempts(clientId), + acks = transaction.acks(clientId), + failures = transaction.failures(clientId), + effects = transaction.effects(clientId), + aliases = transaction.aliases(), + tombstones = transaction.tombstones(), + ) + } + + /** Atomically replaces projection membership and alias routing after successful hydration. */ + internal suspend fun installHydratedState( + hydrated: Map>>, + aliases: Map, + ) { + runtimeState.mutex.withLock { + runtimeState.snapshots.value = + MutationRuntimeSnapshot( + entries = hydrated.mapValues { (_, rows) -> rows.toList() }, + aliases = aliases.toMap(), + ) + } + } + + /** + * Publishes one post-commit alias retirement as a single runtime-cache linearization point. + * The durable edge remains the raw source-to-acknowledged-target fact; queued entries move to + * the already-resolved terminal execution residence. The caller retains the old tombstone + * cache until this method returns, so no observer can see a superseded watermark while + * routing or projection membership is still old. + */ + internal suspend fun publishAliasRetirement( + source: KeyIdentity, + terminalTarget: KeyIdentity, + retiredMutationId: String, + ) { + runtimeState.mutex.withLock { + runtimeState.snapshots.update { current -> + val edge = checkNotNull(current.aliases[source]) { + "Alias retirement requires a published pending edge for $source." + } + val sourceSiblings = + current.entries[source] + .orEmpty() + .filterNot { entry -> entry.mutationId == retiredMutationId } + val mergedTarget = + (current.entries[terminalTarget].orEmpty() + sourceSiblings) + .distinctBy { entry -> entry.mutationId } + .sortedBy { entry -> entry.durableClientSequence } + var updatedEntries = current.entries - source + updatedEntries = + if (mergedTarget.isEmpty()) { + updatedEntries - terminalTarget + } else { + updatedEntries + (terminalTarget to mergedTarget) + } + current.copy( + entries = updatedEntries, + aliases = + current.aliases + + (source to edge.copy(state = AliasEdgeState.ACTIVE)), + ) + } + } + } +} + +/** The default journal, implemented by the public in-memory storage seam. */ +internal class InMemoryMutationJournal : + StorageBackedMutationJournal(storage = InMemoryMutationJournalStorage()) + +// --------------------------------------------------------------------------------------------- +// Cache-fronted canonical alias routing. +// --------------------------------------------------------------------------------------------- + +/** Stable machine detail for a canonical target in a different namespace. */ +internal const val ALIAS_FAILURE_DETAIL_CROSS_NAMESPACE: String = "alias-cross-namespace" + +/** Stable machine detail for a second canonical target claimed for an aliased source. */ +internal const val ALIAS_FAILURE_DETAIL_RETARGET: String = "alias-retarget" + +/** Stable machine detail for a canonical target whose chain reaches back to its source. */ +internal const val ALIAS_FAILURE_DETAIL_CYCLE: String = "alias-cycle" + +/** Stable machine detail for a generation retry acknowledging a different canonical target. */ +internal const val ALIAS_FAILURE_DETAIL_RETRY_TARGET_MISMATCH: String = + "alias-retry-target-mismatch" + +/** + * The lifecycle of one alias edge: `PENDING` between validated acknowledgement receipt and + * retirement, `ACTIVE` from the retirement/activation step onward. Routing follows only `ACTIVE` + * edges; a `PENDING` edge already pins its source against retarget and its idempotency receipt + * against a mismatched retry. + */ +internal enum class AliasEdgeState { + PENDING, + ACTIVE, +} + +/** One normalized same-namespace full-pair redirect: source identity to target identity. */ +internal data class AliasEdge( + val source: KeyIdentity, + val target: KeyIdentity, + val state: AliasEdgeState, + val createdByClientId: String, + val createdBySequence: Long, + val createdAt: Long, +) + +private fun MutationKeyAliasRecord.toAliasEdge(): AliasEdge = + AliasEdge( + source = KeyIdentity(sourceNamespace, sourceCanonicalId), + target = KeyIdentity(targetNamespace, targetCanonicalId), + state = + when (state) { + MutationAliasState.PENDING -> AliasEdgeState.PENDING + MutationAliasState.ACTIVE -> AliasEdgeState.ACTIVE + }, + createdByClientId = createdByClientId, + createdBySequence = createdBySequence, + createdAt = createdAt, + ) + +internal fun List.toAliasEdgesBySource(): Map = + associate { record -> + val edge = record.toAliasEdge() + edge.source to edge + } + +internal fun terminalIdentity( + identity: KeyIdentity, + edges: Map, +): KeyIdentity { + var current = identity + val visited = mutableSetOf() + while (true) { + val edge = edges[current] ?: return current + if (edge.state != AliasEdgeState.ACTIVE) return current + check(visited.add(current)) { + "Alias chain from (${identity.namespace}, ${identity.canonicalId}) cycled at " + + "(${current.namespace}, ${current.canonicalId})." + } + current = edge.target + } +} + +/** The outcome of validating one acknowledged canonical target at ack receipt. */ +internal sealed interface AliasAdmission { + /** + * The acknowledgement is legal. [redirect] is the pending-or-existing edge for a real + * same-namespace redirect, or `null` when the identity is unchanged (null canonical key or + * self-alias — both no-ops by rule). + */ + class Admitted( + val redirect: AliasEdge?, + internal val pendingRecord: MutationKeyAliasRecord?, + internal val idempotencyKey: String, + internal val effectiveTarget: KeyIdentity, + ) : AliasAdmission + + /** The acknowledgement violates the alias protocol; the intent halts without adoption. */ + class Rejected( + val detail: String, + val message: String, + ) : AliasAdmission +} + +/** + * Cache-fronted alias adapter: normalized same-namespace full-pair redirects with + * `PENDING`/`ACTIVE` states, transitive terminal resolution, and generation-idempotency receipt + * tracking. + * + * PENDING and ACTIVE changes commit through [storage] before this synchronous routing cache is + * published. Restart hydration restores both states without advancing an advisory revision. + * Tombstone state is modeled separately from alias edges. + */ +internal class InMemoryAliasRouter( + private val storage: MutationJournalStorage = InMemoryMutationJournalStorage(), + private val runtimeState: MutationRuntimeState<*> = MutationRuntimeState(), +) { + // A retry of one generation idempotency key must return the same canonical target. + // The receipt records the EFFECTIVE canonical identity (the source itself when the ack + // carried no target or a self-alias), so a retry that flips between "unchanged" and a real + // redirect is also a protocol failure. + private val effectiveTargetsByIdempotencyKey = mutableMapOf() + + /** Clears process-only retry receipts after the shared durable snapshot is installed. */ + fun resetAfterHydration() { + effectiveTargetsByIdempotencyKey.clear() + } + + /** + * Resolves the terminal identity for [identity] by following `ACTIVE` edges transitively. + * Cycles are unrepresentable by construction — [admit] rejects them — so the walk guard is + * defensive only. Callers may cache the resolved terminal `K`, but durable edges remain the + * authority (path compression never replaces them). + */ + fun terminalOf(identity: KeyIdentity): KeyIdentity { + return terminalIdentity(identity, runtimeState.aliasesSnapshot()) + } + + /** + * Validates one acknowledged canonical target at ack receipt and prepares, but does not + * persist or publish, an optional pending redirect edge: cross-namespace targets are + * rejected; a receipt mismatch for [idempotencyKey] is rejected; a null or self target is an + * admitted no-op; a duplicate equal edge is idempotent; a different target for an + * already-aliased source is a retarget rejection; a target whose active-or-pending chain + * reaches back to [source] is a cycle rejection. + */ + suspend fun admit( + source: KeyIdentity, + claimed: KeyIdentity?, + idempotencyKey: String, + createdByClientId: String, + createdBySequence: Long, + createdAt: Long, + ): AliasAdmission { + if (claimed != null && claimed.namespace != source.namespace) { + return AliasAdmission.Rejected( + detail = ALIAS_FAILURE_DETAIL_CROSS_NAMESPACE, + message = + "Canonical key (${claimed.namespace}, ${claimed.canonicalId}) crosses the " + + "namespace of (${source.namespace}, ${source.canonicalId}); " + + "cross-namespace rekey is out of the alpha01 contract.", + ) + } + val effectiveTarget = claimed ?: source + val receipt = effectiveTargetsByIdempotencyKey[idempotencyKey] + if (receipt != null && receipt != effectiveTarget) { + return AliasAdmission.Rejected( + detail = ALIAS_FAILURE_DETAIL_RETRY_TARGET_MISMATCH, + message = + "Retry of idempotency key '$idempotencyKey' acknowledged canonical target " + + "(${effectiveTarget.namespace}, ${effectiveTarget.canonicalId}) but " + + "(${receipt.namespace}, ${receipt.canonicalId}) was previously " + + "acknowledged.", + ) + } + if (claimed == null || claimed == source) { + return AliasAdmission.Admitted( + redirect = null, + pendingRecord = null, + idempotencyKey = idempotencyKey, + effectiveTarget = effectiveTarget, + ) + } + val edges = runtimeState.aliasesSnapshot() + val existing = edges[source] + if (existing != null && existing.target != claimed) { + return AliasAdmission.Rejected( + detail = ALIAS_FAILURE_DETAIL_RETARGET, + message = + "Source (${source.namespace}, ${source.canonicalId}) already redirects to " + + "(${existing.target.namespace}, ${existing.target.canonicalId}); " + + "retargeting to (${claimed.namespace}, ${claimed.canonicalId}) is a " + + "protocol violation.", + ) + } + if (existing == null && reaches(edges = edges, from = claimed, needle = source)) { + return AliasAdmission.Rejected( + detail = ALIAS_FAILURE_DETAIL_CYCLE, + message = + "Aliasing (${source.namespace}, ${source.canonicalId}) to " + + "(${claimed.namespace}, ${claimed.canonicalId}) would close an alias " + + "cycle.", + ) + } + val pendingRecord = + if (existing == null) { + MutationKeyAliasRecord( + sourceNamespace = source.namespace, + sourceCanonicalId = source.canonicalId, + targetNamespace = claimed.namespace, + targetCanonicalId = claimed.canonicalId, + state = MutationAliasState.PENDING, + createdByClientId = createdByClientId, + createdBySequence = createdBySequence, + createdAt = createdAt, + activatedAt = null, + ) + } else { + null + } + val edge = + existing + ?: AliasEdge( + source = source, + target = claimed, + state = AliasEdgeState.PENDING, + createdByClientId = createdByClientId, + createdBySequence = createdBySequence, + createdAt = createdAt, + ) + return AliasAdmission.Admitted( + redirect = edge, + pendingRecord = pendingRecord, + idempotencyKey = idempotencyKey, + effectiveTarget = effectiveTarget, + ) + } + + /** Commits a non-durable-engine admission, then publishes its routing cache state. */ + suspend fun commitAdmission(admission: AliasAdmission.Admitted) { + admission.pendingRecord?.let { record -> + storage.transaction { transaction -> transaction.insertAlias(record) } + } + publishAdmission(admission) + } + + /** Publishes an admission only after the caller's durable transaction has committed. */ + fun publishAdmission(admission: AliasAdmission.Admitted) { + runtimeState.updateAliases { edges -> + admission.pendingRecord?.let { record -> + edges + + (KeyIdentity(record.sourceNamespace, record.sourceCanonicalId) to + checkNotNull(admission.redirect)) + } ?: edges + } + effectiveTargetsByIdempotencyKey[admission.idempotencyKey] = admission.effectiveTarget + } + + /** + * Activates the redirect for [source]. Activation is idempotent; the caller performs it + * inside the `NonCancellable` retirement handoff and then synchronously advances the + * mutation-owned alias revision. + */ + suspend fun activate( + source: KeyIdentity, + activatedAt: Long, + ) { + val record = activationRecord(source, activatedAt) ?: return + storage.transaction { transaction -> + transaction.advanceAlias(record) + } + publishActivation(source) + } + + /** Persists a prepared ACTIVE row without publishing a separate routing-cache state. */ + suspend fun persistActivation(record: MutationKeyAliasRecord) { + storage.transaction { transaction -> transaction.advanceAlias(record) } + } + + /** Builds the immutable ACTIVE row without touching storage or the routing cache. */ + fun activationRecord( + source: KeyIdentity, + activatedAt: Long, + ): MutationKeyAliasRecord? { + val edge = runtimeState.aliasesSnapshot()[source] ?: return null + if (edge.state == AliasEdgeState.ACTIVE) return null + return MutationKeyAliasRecord( + sourceNamespace = edge.source.namespace, + sourceCanonicalId = edge.source.canonicalId, + targetNamespace = edge.target.namespace, + targetCanonicalId = edge.target.canonicalId, + state = MutationAliasState.ACTIVE, + createdByClientId = edge.createdByClientId, + createdBySequence = edge.createdBySequence, + createdAt = edge.createdAt, + activatedAt = activatedAt, + ) + } + + /** Publishes a previously committed ACTIVE row to the synchronous routing cache. */ + fun publishActivation(source: KeyIdentity) { + runtimeState.updateAliases { edges -> + val edge = edges[source] ?: return@updateAliases edges + edges + (source to edge.copy(state = AliasEdgeState.ACTIVE)) + } + } + + /** The redirect edge whose source is [source], regardless of state; test/engine door. */ + fun edgeFor(source: KeyIdentity): AliasEdge? = runtimeState.aliasesSnapshot()[source] + + /** Cycle guard: walks pending-or-active edges from [from] looking for [needle]. */ + private fun reaches( + edges: Map, + from: KeyIdentity, + needle: KeyIdentity, + ): Boolean { + var current = from + val visited = mutableSetOf() + while (true) { + if (current == needle) return true + val edge = edges[current] ?: return false + if (!visited.add(current)) return false + current = edge.target + } + } +} diff --git a/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocol.kt b/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocol.kt new file mode 100644 index 000000000..0fd5372d2 --- /dev/null +++ b/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocol.kt @@ -0,0 +1,432 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.mutations + +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreMeta +import org.mobilenativefoundation.store6.core.StoreNamespace + +/** + * Explicit value state carried by every mutation base, mine, and adoption boundary. + * + * [Present] carries a confirmed or projected value; [Absent] is confirmed or projected + * non-existence. A projector returning a nullable `MutationPresence?` uses `null` to mean + * exactly one thing: decline this intent. Deletion is never spelled `null`; it is [Absent]. + * Push, acknowledgement, conflict, attempt, and adoption carriers contain non-null presence, + * never a nullable `V`. + * + * Consumers construct presence directly (upsert arguments, conflict `Retry(presence)`), so both + * variants have public constructors. + */ +@ExperimentalStoreApi +public sealed interface MutationPresence { + /** A value that exists (confirmed) or should exist (projected). */ + @ExperimentalStoreApi + public class Present public constructor( + /** The non-null carried value. */ + @ExperimentalStoreApi + public val value: V, + ) : MutationPresence + + /** + * Confirmed or projected non-existence. + * + * As a base, [Absent] is an existence precondition: apply only if the entity is still + * absent. It is never an unconditional write. + */ + @ExperimentalStoreApi + public object Absent : MutationPresence +} + +/** + * The durable identity pair that alone selects a backend entity. + * + * Durable key identity is exactly `(namespace.value, canonicalId())`. Hashes, object identity, + * and key class are never durable identity. The library alone constructs identities; a resolver + * reconstructs the convenience `K` from this exact pair and the engine validates both returned + * components verbatim. + */ +@ExperimentalStoreApi +public class MutationKeyIdentity internal constructor( + /** Verbatim [StoreNamespace.value] of the effective key. */ + @ExperimentalStoreApi + public val namespace: String, + + /** Verbatim [StoreKey.canonicalId] of the effective key. */ + @ExperimentalStoreApi + public val canonicalId: String, +) + +/** + * Reconstructs a process-local `K` from a durable [MutationKeyIdentity]. + * + * The resolver is a required factory input: restart-safe drain cannot exist without it. It may + * perform I/O and may suspend, but it is never invoked while the global journal transaction is + * held. Returning `null` means the identity cannot be resolved. The engine validates the returned + * key's namespace and canonical id verbatim against the requested pair; a mismatch is rejected + * before any transport work. `CancellationException` is always rethrown. + */ +@ExperimentalStoreApi +public fun interface MutationKeyResolver { + /** Returns the key for [identity], or `null` when the pair cannot be resolved. */ + @ExperimentalStoreApi + public suspend fun resolve(identity: MutationKeyIdentity): K? +} + +/** + * Versioned byte codec for mutation arguments and store values. + * + * Codecs are pure and deterministic for a given version. The library defensively copies every + * encoded array before durable retention and passes a fresh copy into every [decode], so consumer + * code can never mutate a stored retry generation. [decode] receives the persisted version, which + * may be older than the currently registered one; format changes append a version and old + * decoders remain until the corresponding rows are safely retired and pruned. + */ +@ExperimentalStoreApi +public interface MutationCodec { + /** Encodes [value] into bytes owned by the caller after return. */ + @ExperimentalStoreApi + public fun encode(value: T): ByteArray + + /** Decodes [bytes] persisted under [version]; throws when the version or bytes are illegal. */ + @ExperimentalStoreApi + public fun decode(version: Int, bytes: ByteArray): T +} + +/** + * The library-side defensive-copy boundary for codec output: encoded bytes are copied before + * retention so a producer holding the returned array cannot mutate stored state. + */ +internal fun MutationCodec.encodeCopied(value: T): ByteArray = encode(value).copyOf() + +/** + * The library-side defensive-copy boundary for codec input: stored bytes are copied before + * consumer code so a codec retaining its argument cannot mutate a stored retry generation. + */ +internal fun MutationCodec.decodeCopied(version: Int, bytes: ByteArray): T = + decode(version, bytes.copyOf()) + +/** + * The result of a pure registered invalidation function `(key, args) -> StaleSet`. + * + * The library copies, normalizes to full identity pairs, deduplicates, and sorts the returned + * sets before persisting one immutable effect record per key or namespace. The function must be + * pure: equal inputs must produce structurally equal stale sets. + */ +@ExperimentalStoreApi +public class StaleSet public constructor( + /** Keys whose confirmed values become stale after this mutation's adoption. */ + @ExperimentalStoreApi + public val keys: Set, + + /** Namespaces invalidated wholesale after this mutation's adoption. */ + @ExperimentalStoreApi + public val namespaces: Set, +) + +/** + * The library-owned capture carrier handed to the optional precondition selector. + * + * It exists before any transport request: it carries the ordered-capture result and has no + * selected `baseMeta` and no transport door. The selector receives only this candidate — never a + * final [MutationPush] — and its result is immediately copied and frozen with the new attempt + * generation. The selector runs once per newly prepared semantic generation and never on a + * transport retry. + */ +@ExperimentalStoreApi +public class MutationPreconditionCandidate internal constructor( + /** Alone selects the backend entity and feeds preconditions and idempotency. */ + @ExperimentalStoreApi + public val identity: MutationKeyIdentity, + + /** Process-local adapter context; fields beyond its validated identity are non-authoritative. */ + @ExperimentalStoreApi + public val key: K, + + /** The opaque public id of the intent whose generation is being prepared. */ + @ExperimentalStoreApi + public val mutationId: String, + + /** The generation being prepared; a merge prepares `g + 1`, never edits `g`. */ + @ExperimentalStoreApi + public val generation: Int, + + /** The ordered-capture confirmed base; [MutationPresence.Absent] is an existence precondition. */ + @ExperimentalStoreApi + public val base: MutationPresence, + + /** The locally projected outcome for this generation. */ + @ExperimentalStoreApi + public val mine: MutationPresence, + + /** Library-owned snapshot of the captured metadata, before policy selects the frozen result. */ + @ExperimentalStoreApi + public val capturedMeta: StoreMeta?, +) + +/** + * The immutable, library-built transport request for one attempt generation. + * + * `identity` alone selects the backend entity and feeds preconditions and idempotency. `key` is + * process-local adapter context; fields beyond its already-validated namespace and canonical id + * are non-authoritative and may differ across retries. Store6 reconstructs every retry of the + * same semantic generation from the same defensively copied blobs, so a retry carries the same + * base, mine, metadata, advertised prefix, and `idempotencyKey`. A merge that changes payload + * persists generation `g + 1` and a new idempotency key before its first send. + * + * Transport cancellation is not failure: once a push has entered `INFLIGHT`, a thrown + * `CancellationException` is rethrown and leaves that phase intact because remote acceptance is + * uncertain; the next explicit drain or restart sends this exact same immutable generation. + * That uncertain generation retains causal authority for its `(clientId, identity.namespace)`: + * later keys in the same client namespace cannot begin transport until it parks or retires, + * while keys in different namespaces remain eligible to progress. + * + * `baseMeta` strengthens the existence/value precondition when present; `baseMeta == null` never + * means an unconditional write — `base` itself is always a precondition. + */ +@ExperimentalStoreApi +public class MutationPush internal constructor( + /** Alone selects the backend entity and feeds preconditions and idempotency. */ + @ExperimentalStoreApi + public val identity: MutationKeyIdentity, + + /** Process-local adapter context; fields beyond its validated identity are non-authoritative. */ + @ExperimentalStoreApi + public val key: K, + + /** The stable installation/journal identity issuing this push. */ + @ExperimentalStoreApi + public val clientId: String, + + /** The intent's durable per-client sequence; FIFO and watermark unit. */ + @ExperimentalStoreApi + public val clientSequence: Long, + + /** The contiguous retired prefix advertised opportunistically for backend receipt cleanup. */ + @ExperimentalStoreApi + public val retiredThroughSequence: Long, + + /** The opaque public id correlating this push with inspection and events. */ + @ExperimentalStoreApi + public val mutationId: String, + + /** The semantic attempt generation this push transmits; starts at 1. */ + @ExperimentalStoreApi + public val generation: Int, + + /** Generation-stable idempotency key; identical across transport retries of one generation. */ + @ExperimentalStoreApi + public val idempotencyKey: String, + + /** The value-codec version that decodes this generation's base and mine blobs. */ + @ExperimentalStoreApi + public val valueCodecVersion: Int, + + /** The frozen captured base; always an existence/value precondition. */ + @ExperimentalStoreApi + public val base: MutationPresence, + + /** The frozen locally projected outcome to apply. */ + @ExperimentalStoreApi + public val mine: MutationPresence, + + /** The frozen selected precondition metadata; null selects existence/value only. */ + @ExperimentalStoreApi + public val baseMeta: StoreMeta?, +) + +/** + * The application-owned mutation transport. + * + * Implementations own deterministic wire encoding of the library-built carriers. A precondition + * conflict is signalled only through the callable public core door: + * + * ```kotlin + * throw StoreResults.exception( + * StoreResults.conflict(serverMeta, message), + * cause, + * ) + * ``` + */ +// docs:snippet:mutations-server-interface +@ExperimentalStoreApi +public interface MutationServer { + /** + * Pushes one immutable attempt generation and returns the backend's acknowledgement. + * + * Retries of one [MutationPush.idempotencyKey] must be idempotent: a duplicate success must + * return the same authoritative presence, value, etag, and canonical target. A thrown + * `CancellationException` is rethrown by the library and leaves the in-flight generation + * intact for exact replay. + * + * Backend coherence obligation for confirmed deletion, certified by returning + * [MutationAbsentAck]: Every fetch begun after an Absent acknowledgement returns + * `FetcherResult.Deleted`. Tombstones stop journal replay; they do not mask a backend that + * violates this contract. + */ + @ExperimentalStoreApi + public suspend fun push(request: MutationPush): MutationAck + + /** + * Confirms a monotonic retirement checkpoint so the backend can bound idempotency-receipt + * retention. + * + * The returned [MutationRetirementAck.confirmedThroughSequence] must be monotonic and cannot + * exceed [MutationRetirement.retiredThroughSequence]; the library validates both properties + * and treats a violation as a protocol failure. The request/ack protocol is idempotent: a + * later pass may resend the same or a greater prefix. A thrown `CancellationException` is + * rethrown, leaves the server-confirmed prefix unchanged, and prunes nothing. + */ + @ExperimentalStoreApi + public suspend fun retire(request: MutationRetirement): MutationRetirementAck +} +// docs:snippet:end + +/** + * The backend's acknowledgement of one pushed generation. + * + * The sealed variants make a canonical target on confirmed absence unrepresentable: only + * [MutationPresentAck] can carry a canonical key. Consumers construct the legal variants; the + * library alone constructs pushes, retirements, identities, inspection rows, and failures. + */ +@ExperimentalStoreApi +public sealed interface MutationAck { + /** Optional backend entity tag recorded by Store freshness bookkeeping. */ + @ExperimentalStoreApi + public val etag: String? +} + +/** + * A confirmed present outcome, adopted through write-handle apply then fresh confirmation. + * + * `canonicalKey` optionally redirects a provisional identity to its same-namespace canonical + * identity; `null` means the identity is unchanged. A retry of one generation idempotency key + * must return the same canonical target or the intent parks as a protocol violation. + */ +@ExperimentalStoreApi +public class MutationPresentAck public constructor( + /** The backend-authoritative value written into Store's source of truth. */ + @ExperimentalStoreApi + public val authoritative: V, + + @ExperimentalStoreApi + public override val etag: String?, + + /** Optional same-namespace canonical redirect; `null` keeps the pushed identity. */ + @ExperimentalStoreApi + public val canonicalKey: K?, +) : MutationAck + +/** + * A confirmed absent outcome, adopted through the delegated Store's `clear(key)`. + * + * Backend coherence obligation: Every fetch begun after an Absent acknowledgement returns + * `FetcherResult.Deleted`. Tombstones stop journal replay; they do not mask a backend that + * violates this contract. This variant deliberately has no canonical key, so rekey-on-deletion + * is unrepresentable in alpha01. + */ +@ExperimentalStoreApi +public class MutationAbsentAck public constructor( + @ExperimentalStoreApi + public override val etag: String?, +) : MutationAck + +/** + * The library-built retirement checkpoint request. + * + * A checkpoint confirms only the current contiguous retired prefix; it never advances across + * parked or active work. + */ +@ExperimentalStoreApi +public class MutationRetirement internal constructor( + /** The stable installation/journal identity whose prefix is being confirmed. */ + @ExperimentalStoreApi + public val clientId: String, + + /** The contiguous locally retired prefix offered for confirmation. */ + @ExperimentalStoreApi + public val retiredThroughSequence: Long, +) + +/** + * The consumer-built confirmation of a retirement checkpoint. + * + * Confirmation is monotonic and cannot exceed its request; the library validates both before + * persisting the server-confirmed prefix, so this carrier stays plain. + */ +@ExperimentalStoreApi +public class MutationRetirementAck public constructor( + /** The largest client sequence the backend has durably confirmed as retired. */ + @ExperimentalStoreApi + public val confirmedThroughSequence: Long, +) + +/** + * Library-side retirement-ack validation: the engine calls this when processing a + * [MutationServer.retire] response, so the public carriers stay plain data. + * + * Rejects a confirmation above the requested prefix and any regression below the previously + * persisted server-confirmed prefix. Returns the validated new confirmed prefix. The engine + * normalizes the thrown failure as `MutationFailureKind.PROTOCOL`. + */ +internal fun validateRetirementAck( + request: MutationRetirement, + ack: MutationRetirementAck, + previousConfirmedThroughSequence: Long, +): Long { + require(ack.confirmedThroughSequence <= request.retiredThroughSequence) { + "Retirement confirmation ${ack.confirmedThroughSequence} exceeds requested prefix " + + "${request.retiredThroughSequence} for client '${request.clientId}'." + } + require(ack.confirmedThroughSequence >= previousConfirmedThroughSequence) { + "Retirement confirmation ${ack.confirmedThroughSequence} regresses below " + + "$previousConfirmedThroughSequence for client '${request.clientId}'." + } + return ack.confirmedThroughSequence +} + +/** + * Library-side exact-pair resolution validation: the engine calls this on every resolver + * result before any transport or adoption work. + * + * Resolver null and identity mismatch fail with `cause == null`; the original resolver context + * never enters durable state. + */ +internal fun requireResolvedKey( + identity: MutationKeyIdentity, + resolved: K?, +): K { + checkNotNull(resolved) { + "MutationKeyResolver returned null for (${identity.namespace}, ${identity.canonicalId})." + } + check( + resolved.namespace.value == identity.namespace && + resolved.canonicalId() == identity.canonicalId, + ) { + "MutationKeyResolver returned (${resolved.namespace.value}, ${resolved.canonicalId()}) " + + "for requested identity (${identity.namespace}, ${identity.canonicalId})." + } + return resolved +} + +/** + * The explicit outcome of a consumer merge hook after a precondition conflict. + * + * [Retry] persists a new generation before transmission; [ServerWins] retires the intent without + * another push. Without an installed merge, server-wins is the non-removable terminal. + */ +@ExperimentalStoreApi +public sealed interface MutationConflictResolution { + /** Retry with a merged outcome; the library persists generation `g + 1` before sending. */ + @ExperimentalStoreApi + public class Retry public constructor( + /** The merged outcome to apply; presence, never nullable `V`. */ + @ExperimentalStoreApi + public val value: MutationPresence, + ) : MutationConflictResolution + + /** Accept the authoritative server state and retire without another push. */ + @ExperimentalStoreApi + public object ServerWins : MutationConflictResolution +} diff --git a/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationSourceOfTruth.kt b/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationSourceOfTruth.kt new file mode 100644 index 000000000..c779ec2a7 --- /dev/null +++ b/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationSourceOfTruth.kt @@ -0,0 +1,108 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, +) + +package org.mobilenativefoundation.store6.mutations + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth + +/** + * Mutations-owned in-memory [SourceOfTruth] installed when the builder's persistence door is + * unset. + * + * The factory forwards this exact instance to both the delegated core Store and the mutation + * engine; core's inaccessible internal default is never substituted. Certified against + * `SourceOfTruthContractKit`: + * + * - Each row lives in a per-identity versioned state cell keyed on + * `(namespace.value, canonicalId())`. Every collection of [reader] immediately first emits the + * current row, or `null` when absent, and never completes normally. + * - Mutations bump the cell version, so an equal-value rewrite still notifies active collections + * despite state-flow conflation of equal values. + * - Cells are retained across [delete], [deleteNamespace], and [deleteAll], so active readers + * receive `null` and remain live for later writes; key isolation follows from per-identity + * cells. + * - Mutations mutate one cell reference under the lock with no intervening suspension, so + * completion is exception-atomic for every `Throwable`, including cancellation: normal return + * means the mutation was applied and its final row notification published, while throwing means + * it was not applied. + * + * Cells are intentionally unbounded, matching the core default's posture. + */ +internal class MutationSourceOfTruth : SourceOfTruth { + private class Row( + val value: V?, + val version: Long, + ) + + private val lock = Mutex() + private val rows = HashMap>>() + + override fun reader(key: K): Flow = + flow { + emitAll(rowFor(key).map { row -> row.value }) + } + + override suspend fun write( + key: K, + value: V, + ) { + publish(key.identity(), value) + } + + override suspend fun delete(key: K) { + publish(key.identity(), null) + } + + override suspend fun deleteNamespace(namespace: StoreNamespace) { + lock.withLock { + rows.forEach { (identity, row) -> + if (identity.namespace == namespace.value) { + row.publishNull() + } + } + } + } + + override suspend fun deleteAll() { + lock.withLock { + rows.values.forEach { row -> row.publishNull() } + } + } + + private suspend fun rowFor(key: K): MutableStateFlow> { + val identity = key.identity() + return lock.withLock { rowFor(identity) } + } + + private suspend fun publish( + identity: KeyIdentity, + value: V?, + ) { + lock.withLock { + val row = rowFor(identity) + val current = row.value + row.value = Row(value = value, version = current.version + 1L) + } + } + + private fun rowFor(identity: KeyIdentity): MutableStateFlow> = + rows.getOrPut(identity) { + MutableStateFlow(Row(value = null, version = 0L)) + } + + private fun MutableStateFlow>.publishNull() { + val current = this.value + this.value = Row(value = null, version = current.version + 1L) + } +} diff --git a/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt b/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt new file mode 100644 index 000000000..18b78db7b --- /dev/null +++ b/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt @@ -0,0 +1,570 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, +) + +package org.mobilenativefoundation.store6.mutations + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.transformWhile +import kotlinx.coroutines.launch +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreException +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.store +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.core.seam.KeyEvents +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import org.mobilenativefoundation.store6.core.seam.StoreResults +import org.mobilenativefoundation.store6.core.seam.runtime +import org.mobilenativefoundation.store6.mutations.storage.MutationEffectRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationKeyTombstoneRecord + +/** + * Narrows a [Store] to journalled mutation writes while preserving Store reads and maintenance, + * and routes every key-taking operation through the canonical alias table. + * + * This facade deliberately withholds the raw engine write handle. Calling `runtime()` on it + * returns `null`; `keyEvents` is re-published so advisory access survives that narrowing, and it + * gains no `Rekeyed` variant. [close] marks the facade closed before closing its delegate, and + * mutation operations then fail with Store's exact closed-store contract. + * + * Canonical routing: every key-taking method resolves the terminal identity of its key before + * delegating — a key whose identity no active alias redirects IS terminal and is used as given, + * without consulting the resolver. For an aliased identity the retained + * [MutationKeyResolver] reconstructs the canonical `K` (exact-pair validated); the suspending + * methods make ONE resolution attempt and throw a `StoreResults.conversionError`-backed + * [StoreException] on failure, while [stream] emits the sanctioned conversion error and stays + * live. Raw [Store] references and foreign graphs do not follow the alias guarantee. + */ +@ExperimentalStoreApi +public class MutationStore internal constructor( + private val delegate: Store, + private val engine: MutationEngine, + + /** The exact advisory event flow captured from the delegate runtime during construction. */ + @ExperimentalStoreApi + public val keyEvents: Flow, +) : Store by delegate { + private val closed = MutableStateFlow(false) + + /** + * Observes retrieval state and values for the terminal canonical identity of [key]. + * + * Alias liveness contract: before resolving, the stream snapshots the mutation-owned + * stateful revision for the terminal identity. On resolver null, throw, or identity mismatch + * it emits exactly one `StoreResults.error(StoreResults.conversionError(message, cause), + * servedStale = false)`, never delegates to the stale source key, never completes, and + * suspends until a strictly newer revision for that identity — a later alias activation or a + * non-stream facade resolution attempt — then retries; its own attempt never advances the + * revision, and a new collection attempts resolution immediately. On success it collects the + * terminal delegate stream and, after a later activation redirects the identity, re-resolves + * and swaps to the new canonical delegate stream. [close] cancels a waiting collector + * promptly and releases its retry subscription. + */ + override fun stream( + key: K, + freshness: Freshness, + ): Flow> = + flow { + if (closed.value) throw IllegalStateException("Store is closed.") + engine.ensureHydrated() + var firstAttempt = true + while (true) { + if (closed.value) { + if (firstAttempt) throw IllegalStateException("Store is closed.") + throw CancellationException("Store is closed.") + } + firstAttempt = false + // Snapshot the mutation-owned stateful signals BEFORE resolving so an + // activation or facade attempt landing mid-resolution can never be lost. + val signalSnapshot = facadeSignalSnapshot(key.identity()) + val terminal = signalSnapshot.terminalIdentity + val aliasRevisions = signalSnapshot.aliasRevisions + val resolutionPulses = signalSnapshot.resolutionPulses + val aliasSnapshot = signalSnapshot.aliasRevisionBaseline + val pulseSnapshot = signalSnapshot.resolutionPulseBaseline + val resolution = engine.resolveTerminalKey(key) + if (resolution.identity != terminal) { + // An activation moved the terminal between the snapshot and the attempt; + // the snapshots guard the wrong identity, so re-attempt immediately. + continue + } + when (resolution) { + is TerminalKeyResolution.Failed -> { + emit( + StoreResults.error( + StoreResults.conversionError( + resolution.message, + resolution.cause, + ), + servedStale = false, + ), + ) + val woke = + merge( + resolutionPulses + .filter { revision -> revision > pulseSnapshot } + .map { true }, + closed.filter { it }.map { false }, + ).first() + if (!woke) throw CancellationException("Store is closed.") + } + is TerminalKeyResolution.Resolved -> { + when ( + awaitProjectionFence( + key = resolution.key, + requiredProjectionStamp = + signalSnapshot.requiredProjectionStamp, + appliedProjectionStamps = + signalSnapshot.targetAppliedProjectionStamps, + aliasRevisions = aliasRevisions, + aliasSnapshot = aliasSnapshot, + ) + ) { + ProjectionFenceOutcome.Applied -> Unit + ProjectionFenceOutcome.Rerouted -> continue + ProjectionFenceOutcome.Closed -> + throw CancellationException("Store is closed.") + } + var completedNormally = false + var rerouted = false + val guarded = + merge( + delegate + .stream(resolution.key, freshness) + .map, FacadeStreamElement> { result -> + FacadeStreamElement.Emission(result) + } + .onCompletion { failure -> + if (failure == null) { + emit(FacadeStreamElement.Completed) + } + }, + aliasRevisions + .filter { revision -> revision > aliasSnapshot } + .map> { + FacadeStreamElement.Rerouted + }, + ) + emitAll( + guarded.transformWhile { element -> + when (element) { + is FacadeStreamElement.Emission -> { + emit(element.result) + true + } + FacadeStreamElement.Completed -> { + completedNormally = true + false + } + FacadeStreamElement.Rerouted -> { + rerouted = true + false + } + } + }, + ) + if (completedNormally) return@flow + check(rerouted) { + "Alias-guarded stream stopped without completion or reroute." + } + // Rerouted: loop, re-resolve the strictly newer terminal, and swap to + // the canonical delegate stream. + } + } + } + } + + /** + * Returns the value for the terminal canonical identity of [key]; one resolution attempt, + * throwing a `StoreResults.conversionError`-backed [StoreException] on failure. This read + * is never projected by the overlay; overlays apply only to [stream]. + */ + override suspend fun get( + key: K, + freshness: Freshness, + ): V { + checkOpen() + return delegate.get(engine.requireTerminalKey(key), freshness) + } + + /** + * Marks the terminal canonical identity of [key] stale; one resolution attempt, throwing a + * `StoreResults.conversionError`-backed [StoreException] on failure. + */ + override suspend fun invalidate(key: K) { + checkOpen() + delegate.invalidate(engine.requireTerminalKey(key)) + } + + /** + * Destructively removes the value for the terminal canonical identity of [key]; one resolution + * attempt, throwing a `StoreResults.conversionError`-backed [StoreException] on failure. + */ + override suspend fun clear(key: K) { + checkOpen() + delegate.clear(engine.requireTerminalKey(key)) + } + + // invalidateNamespace, invalidateAll, clearNamespace, and clearAll need no key and delegate + // directly through `Store by delegate`. + + /** + * Appends one typed intent to the journal and signals optimistic reprojection. + * + * The terminal canonical identity is resolved BEFORE the append: the intent is journalled + * at the effective identity so queued siblings merge by durable client sequence, + * and a resolution failure throws the sanctioned `StoreResults.conversionError`-backed + * [StoreException] without creating any intent. + * + * @return the opaque mutation id assigned at enqueue, correlating this intent with + * [pending], [pendingWrites], [deadLetters], and its [MutationIntentEvent]s + * @throws IllegalArgumentException if [ref] does not belong to the [MutatorRegistry] this + * store was created with + */ + @ExperimentalStoreApi + public suspend fun mutate( + key: K, + ref: MutatorRef, + args: A, + ): String { + checkOpen() + return engine.mutate(key, ref, args) + } + + /** + * Runs one idempotent, scheduler-agnostic foreground pass for the terminal canonical + * identity of [key]: the engine captures the unprojected confirmed base through the ordered + * `status -> LocalOnly` loop and pushes the pending FIFO prefix once, with no + * retry or backoff. This pass never fetches. A terminal pre-transport identity, codec, or + * projection failure durably parks and removes the affected head; push failure remains + * retryable and stops normally; adoption failure propagates; an alias-protocol violation + * records a normalized `PROTOCOL` carrier until its later acknowledgement-validation rung. + * Terminal resolution makes one attempt and returns normally after parking an affected + * pre-ack head. Once transport becomes possible or uncertain, that durable execution owns + * its client namespace until it parks or retires: a keyed drain for another key in that + * namespace returns without transport. Different namespaces remain eligible to progress, + * and alias activation re-homes the owning pass before canonical adoption continues. + */ + @ExperimentalStoreApi + public suspend fun drain(key: K) { + checkOpen() + engine.drainKeyResolvedOrRecord(key) { checkOpen() } + } + + /** + * Runs one idempotent, scheduler-agnostic global foreground pass: every durable identity + * is enumerated from the journal and reconstructed through the required + * [MutationKeyResolver] with exact-pair validation. An identity that fails to resolve + * parks its affected pre-ack head and does not block the others. Global processing is + * deterministic by durable client sequence within an effective identity and does not promise + * cross-key order. A post-transport owner resumes before another key in its client namespace; + * those nonowners wait for a later trigger, while every eligible different namespace remains + * able to progress. Without an owner, independent keys acquire authority by execution rather + * than by durable sequence. + */ + @ExperimentalStoreApi + public suspend fun drain() { + checkOpen() + engine.drain { checkOpen() } + } + + /** + * Returns the current pending intents for the terminal canonical identity of [key] in + * durable client-sequence FIFO order. + * + * Aliases are followed as durable identity pairs only: this inspection never + * reconstructs a `K`, never consults the resolver, and therefore cannot fail on an + * unresolvable canonical key. + */ + @ExperimentalStoreApi + public suspend fun pending(key: K): List { + checkOpen() + engine.ensureHydrated() + return engine.pendingForIdentity(key.identity()) + } + + /** + * Returns a truthful snapshot of every nonterminal active intent across all durable + * identities, in durable client-sequence order. Retired history never appears. + */ + @ExperimentalStoreApi + public suspend fun pendingWrites(): List { + checkOpen() + return engine.pendingWrites() + } + + /** + * Returns the durably parked intents. Dead letters contain only `PARKED` entries; + * retired history never appears and post-acknowledgement work never parks. + */ + @ExperimentalStoreApi + public suspend fun deadLetters(): List { + checkOpen() + return engine.deadLetters() + } + + /** The replay-buffered projection-failure containment stream owned by the mutation engine. */ + @ExperimentalStoreApi + public val poisoned: SharedFlow + get() = engine.poisoned + + /** + * Read-only, in-process advisory lifecycle events: replay `0`, extra buffer capacity + * `64`, overflow `DROP_OLDEST`, non-blocking emission. Never a drain, acknowledgement, retry, + * or settlement protocol; durable truth remains inspection. + */ + @ExperimentalStoreApi + public val events: SharedFlow + get() = engine.eventBus.events + + /** The exact Bookkeeper the engine retained; the verification door for retention tests. */ + internal val bookkeeperRetainedByEngine: Bookkeeper + get() = engine.bookkeeper + + /** The exact SourceOfTruth the engine retained; the verification door for retention tests. */ + internal val sourceOfTruthRetainedByEngine: SourceOfTruth + get() = engine.sourceOfTruth + + internal fun emittedProjectionStamp(identity: KeyIdentity): StateFlow = + engine.emittedProjectionStamp(identity) + + internal fun appliedProjectionStamp(identity: KeyIdentity): StateFlow = + engine.appliedProjectionStamp(identity) + + internal fun durableEffectsForInspection(mutationId: String): List = + engine.durableEffectsSnapshot(mutationId) + + internal fun tombstonesForInspection( + namespace: String, + canonicalId: String, + ): List = + engine.tombstoneSnapshot(KeyIdentity(namespace, canonicalId)) + + override fun close() { + // The stateful transition wakes any stream suspended on a resolver-retry subscription: + // the waiter observes the closed signal, cancels promptly, and its `first` releases the + // retry subscription. + closed.value = true + delegate.close() + } + + private fun checkOpen() { + if (closed.value) { + throw IllegalStateException("Store is closed.") + } + } + + private suspend fun facadeSignalSnapshot(identity: KeyIdentity): FacadeSignalSnapshot { + val snapshot = + merge( + flow { + emit(engine.facadeSignalSnapshot(identity)) + }, + closed + .filter { it } + .map { null }, + ).first() + if (closed.value || snapshot == null) { + throw CancellationException("Store is closed.") + } + return snapshot + } + + private suspend fun awaitProjectionFence( + key: K, + requiredProjectionStamp: Long, + appliedProjectionStamps: StateFlow, + aliasRevisions: StateFlow, + aliasSnapshot: Long, + ): ProjectionFenceOutcome = + coroutineScope { + val pin = + launch(start = CoroutineStart.UNDISPATCHED) { + try { + delegate.stream(key, Freshness.LocalOnly).collect {} + } catch (failure: Throwable) { + if (!closed.value) throw failure + } + } + try { + readyProjectionFenceOutcome( + requiredProjectionStamp = requiredProjectionStamp, + appliedProjectionStamps = appliedProjectionStamps, + aliasRevisions = aliasRevisions, + aliasSnapshot = aliasSnapshot, + ) ?: run { + merge( + appliedProjectionStamps + .filter { stamp -> stamp >= requiredProjectionStamp } + .map { ProjectionFenceOutcome.Applied }, + aliasRevisions + .filter { revision -> revision > aliasSnapshot } + .map { ProjectionFenceOutcome.Rerouted }, + closed.filter { it }.map { ProjectionFenceOutcome.Closed }, + ).first() + checkNotNull( + readyProjectionFenceOutcome( + requiredProjectionStamp = requiredProjectionStamp, + appliedProjectionStamps = appliedProjectionStamps, + aliasRevisions = aliasRevisions, + aliasSnapshot = aliasSnapshot, + ), + ) { + "Projection fence woke without a satisfied outcome." + } + } + } finally { + pin.cancelAndJoin() + } + } + + private fun readyProjectionFenceOutcome( + requiredProjectionStamp: Long, + appliedProjectionStamps: StateFlow, + aliasRevisions: StateFlow, + aliasSnapshot: Long, + ): ProjectionFenceOutcome? { + if (closed.value) return ProjectionFenceOutcome.Closed + if (aliasRevisions.value > aliasSnapshot) return ProjectionFenceOutcome.Rerouted + if (appliedProjectionStamps.value < requiredProjectionStamp) return null + if (closed.value) return ProjectionFenceOutcome.Closed + if (aliasRevisions.value > aliasSnapshot) return ProjectionFenceOutcome.Rerouted + return ProjectionFenceOutcome.Applied + } +} + +private enum class ProjectionFenceOutcome { + Applied, + Rerouted, + Closed, +} + +/** One element of the alias-guarded facade stream: an emission, completion, or reroute marker. */ +private sealed interface FacadeStreamElement { + class Emission( + val result: StoreResult, + ) : FacadeStreamElement + + object Completed : FacadeStreamElement + + object Rerouted : FacadeStreamElement +} + +/** + * Builds a Store whose sole overlay is the mutation engine and returns its narrowed facade. + * + * This is the entry point: restart behavior is compile-time required — the registry, server, + * key resolver, and value codec/version are factory inputs, and optional Store configuration + * lives on [MutationStoreBuilder], which exposes no overlay door. The retained + * [SourceOfTruth]/[Bookkeeper] selections (or mutations-owned defaults) are installed in the + * delegated Store AND retained by the engine, so the selection is never an inaccessible core + * default. The delegate runtime is captured exactly once, its write handle is bound privately, + * and neither runtime nor handle is exposed. The engine's confirmed-base reader and + * confirmed-absence adoption door close over the delegate through the same construction cycle + * the write handle uses: the overlay must be installed at build time, so both lambdas are bound + * before the delegate exists and first run only after it does. + * + * @throws IllegalArgumentException if [valueCodecVersion] is not positive, or if [configure] + * installs no `fetcher { }`, `fetcherOfResult { }`, or `fetcher(Fetcher)` door + */ +@ExperimentalStoreApi +public fun mutationStore( + registry: MutatorRegistry, + server: MutationServer, + keyResolver: MutationKeyResolver, + valueCodecVersion: Int, + valueCodec: MutationCodec, + configure: MutationStoreBuilder.() -> Unit, +): MutationStore { + require(valueCodecVersion >= 1) { + "valueCodecVersion must be positive." + } + val configuration = MutationStoreBuilder().apply(configure).snapshot() + var boundDelegate: Store? = null + val engine = + MutationEngine( + registry = registry, + server = server, + journal = + StorageBackedMutationJournal( + storage = configuration.journalStorage, + registrations = registry.registrations, + hydrateOnFirstUse = true, + ), + keyResolver = keyResolver, + valueCodecVersion = valueCodecVersion, + valueCodec = valueCodec, + conflicts = configuration.conflicts, + bookkeeper = configuration.bookkeeper, + sourceOfTruth = configuration.sourceOfTruth, + baseReader = { key -> checkNotNull(boundDelegate).confirmedBaseOrNull(key) }, + freshnessBarrier = { key -> checkNotNull(boundDelegate).completeFreshnessBarrier(key) }, + absentAdoption = { key -> checkNotNull(boundDelegate).clear(key) }, + namespaceInvalidation = { namespace -> + checkNotNull(boundDelegate).invalidateNamespace(namespace) + }, + wallClock = configuration.wallClock ?: MutationsSystemWallClock, + ) + val delegate = + store { + configuration.applyCoreConfiguration(this) + overlay(engine.overlay) + } + boundDelegate = delegate + val runtime = checkNotNull(delegate.runtime()) + engine.bind(runtime.writeHandle) + return MutationStore( + delegate = delegate, + engine = engine, + keyEvents = runtime.keyEvents, + ) +} + +/** + * Completes the conflict freshness barrier without using its returned value as authoritative + * `theirs`. A server-confirmed deletion satisfies the barrier; every other failure propagates. + */ +private suspend fun Store.completeFreshnessBarrier(key: K) { + try { + get(key, Freshness.MustBeFresh) + Unit + } catch (failure: StoreException) { + if (failure.error !is StoreError.Missing) throw failure + } +} + +/** + * Reads the unprojected confirmed base with [Freshness.LocalOnly]; a missing value becomes + * `null` for the engine's ordered capture loop. This read never fetches. + */ +private suspend fun Store.confirmedBaseOrNull(key: K): V? = + try { + get(key, Freshness.LocalOnly) + } catch (failure: StoreException) { + if (failure.error is StoreError.Missing) { + null + } else { + throw failure + } + } diff --git a/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt b/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt new file mode 100644 index 000000000..0bdbaa834 --- /dev/null +++ b/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt @@ -0,0 +1,314 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, +) + +package org.mobilenativefoundation.store6.mutations + +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreBuilder +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreMeta +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.core.seam.Fetcher +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import org.mobilenativefoundation.store6.core.seam.FreshnessValidator +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import org.mobilenativefoundation.store6.core.seam.StoreTelemetry +import org.mobilenativefoundation.store6.core.seam.WallClock +import org.mobilenativefoundation.store6.mutations.storage.InMemoryMutationJournalStorage +import org.mobilenativefoundation.store6.mutations.storage.MutationJournalStorage + +/** + * Configuration receiver for the [mutationStore] creation DSL. + * + * Mirrors the optional core [StoreBuilder] doors a mutation store may configure and deliberately + * exposes no `overlay` door: the mutation engine's overlay is a mutation store's sole projection + * layer and is installed by the factory. The required mutation inputs — registry, server, key + * resolver, and value codec/version — are [mutationStore] factory parameters, never builder + * doors. + * + * The builder retains the exact selected-or-default [SourceOfTruth] and [Bookkeeper] so the + * factory forwards the same instances to both the delegated core Store and the mutation engine. + * When either seam is left unset, a mutations-owned contract-certified default is created for it + * and that same instance is passed to both sides; core's inaccessible internal defaults are never + * silently substituted. + */ +@ExperimentalStoreApi +public class MutationStoreBuilder internal constructor() { + private var installFetcher: (StoreBuilder.() -> Unit)? = null + private var sot: SourceOfTruth? = null + private var bookkeeper: Bookkeeper? = null + private var telemetry: StoreTelemetry? = null + private var wallClock: WallClock? = null + private var validator: FreshnessValidator? = null + private var maxIdleKeys: Int? = null + private var conflicts: MutationConflictRegistration? = null + private var journalStorage: MutationJournalStorage? = null + private var snapshot: MutationStoreConfiguration? = null + + /** + * Configures the suspending function used to retrieve a value for a key. + * + * Success-or-throw sugar forwarded to the core builder's identical door. The last registration + * wins across this function, [fetcherOfResult], and the regular-interface [fetcher] overload. + * + * @param fetch the function that retrieves and returns a value for the supplied key + */ + @ExperimentalStoreApi + public fun fetcher(fetch: suspend (K) -> V) { + installFetcher = { this.fetcher(fetch) } + } + + /** + * Configures a fetcher that returns the full [FetcherResult] vocabulary. + * + * Forwarded to the core builder's identical door. The last registration wins across all three + * fetcher install points. + * + * @param fetch the function that returns a rich result for the supplied key + */ + @ExperimentalStoreApi + public fun fetcherOfResult(fetch: suspend (K) -> FetcherResult) { + installFetcher = { this.fetcherOfResult(fetch) } + } + + /** + * Installs a regular-interface [Fetcher] that can receive conditional-request ETags. + * + * Forwarded to the core builder's identical door. The last registration wins across all three + * fetcher install points. + * + * @param fetcher the fetch source installed for this store + */ + @ExperimentalStoreApi + public fun fetcher(fetcher: Fetcher) { + installFetcher = { this.fetcher(fetcher) } + } + + /** + * Selects the persistence seam for this mutation store. + * + * The exact instance is retained and forwarded to both the delegated core Store and the + * mutation engine; leaving this unset installs one mutations-owned in-memory default on both + * sides. Custom implementations should be validated with the source-of-truth contract kit. + * + * @param sot the source of truth used by the store and retained for the mutation engine + */ + @ExperimentalStoreApi + public fun persistence(sot: SourceOfTruth) { + this.sot = sot + } + + /** + * Installs the durable freshness bookkeeping implementation used by this mutation store. + * + * The exact instance is retained and forwarded to both the delegated core Store and the + * mutation engine, so mutation metadata capture reads the same authority the Store updates. + * Leaving this unset installs one mutations-owned in-memory default on both sides. + * + * @param bookkeeper the bookkeeper used by the store and retained for the mutation engine + */ + @ExperimentalStoreApi + public fun bookkeeper(bookkeeper: Bookkeeper) { + this.bookkeeper = bookkeeper + } + + /** Installs a non-blocking lifecycle observer; leaving it unset preserves the null fast path. */ + @ExperimentalStoreApi + public fun telemetry(telemetry: StoreTelemetry) { + this.telemetry = telemetry + } + + /** Installs the wall clock used for age and freshness-bound calculations. */ + @ExperimentalStoreApi + public fun wallClock(wallClock: WallClock) { + this.wallClock = wallClock + } + + /** Installs the policy planner used to select a fetch plan for each coherent read snapshot. */ + @ExperimentalStoreApi + public fun freshnessValidator(validator: FreshnessValidator) { + this.validator = validator + } + + /** + * Bounds quiescent per-key engine residency, forwarded to the core builder's identical door. + * + * @param count the maximum number of quiescent engines retained; must be >= 0. Default 128. + */ + @ExperimentalStoreApi + public fun maxIdleKeys(count: Int) { + require(count >= 0) { "maxIdleKeys must be >= 0, was $count." } + maxIdleKeys = count + } + + /** + * Registers the conflict policy for this mutation store. + * + * Without a registered merge, server-wins is the non-removable terminal. The last + * registration of this door wins, mirroring every other builder door; within one + * [MutationConflictBuilder] block each policy registers at most once. + * + * @param configure conflict policy registration applied before the store is created + */ + @ExperimentalStoreApi + public fun conflicts(configure: MutationConflictBuilder.() -> Unit) { + conflicts = MutationConflictBuilder().apply(configure).build() + } + + /** + * Selects the durable mutation-journal storage for this store. + * + * The exact instance is retained by the mutation engine. Leaving this door unset creates one + * mutations-owned [InMemoryMutationJournalStorage], so the journal stays in memory. + * Reopening a store over the same durable instance enables restart hydration. + * + * @param storage the journal storage instance owned by this mutation store + */ + @ExperimentalStoreApi + public fun journalStorage(storage: MutationJournalStorage) { + journalStorage = storage + } + + /** + * Materializes the retained configuration exactly once. + * + * The first call creates the mutations-owned defaults for any unset seam; every later call + * returns the same snapshot, so the factory and any verification path observe identical + * retained instances. + */ + internal fun snapshot(): MutationStoreConfiguration { + snapshot?.let { return it } + val installFetcher = requireNotNull(installFetcher) { + "mutationStore { } requires a fetcher { }, fetcherOfResult { }, or " + + "fetcher(Fetcher) door." + } + return MutationStoreConfiguration( + installFetcher = installFetcher, + sourceOfTruth = sot ?: MutationSourceOfTruth(), + bookkeeper = bookkeeper ?: MutationBookkeeper(), + telemetry = telemetry, + wallClock = wallClock, + freshnessValidator = validator, + maxIdleKeys = maxIdleKeys, + conflicts = conflicts, + journalStorage = journalStorage ?: InMemoryMutationJournalStorage(), + ).also { snapshot = it } + } +} + +/** + * Registers the conflict policy surface. + * + * Registration is validated as it happens: each policy registers at most once per block. There is + * deliberately no terminal setter: server-wins is always present. + */ +@ExperimentalStoreApi +public class MutationConflictBuilder internal constructor() { + private var precondition: ((MutationPreconditionCandidate) -> StoreMeta?)? = null + private var merge: ( + ( + base: MutationPresence, + mine: MutationPresence, + theirs: MutationPresence, + ) -> MutationConflictResolution + )? = null + + /** + * Registers the pure precondition metadata selector. + * + * The selector receives only the library-owned [MutationPreconditionCandidate]; it never + * receives or mutates a final push. Returning null deliberately selects an existence/value + * precondition without metadata — it never removes the base precondition. Without a selector, + * Store6 selects the candidate's captured metadata. The selector runs once per newly prepared + * semantic generation, never on a transport retry. + * + * @param select the pure selector applied to each prepared generation's candidate + */ + @ExperimentalStoreApi + public fun precondition(select: (MutationPreconditionCandidate) -> StoreMeta?) { + require(this.precondition == null) { + "conflicts { } already registered a precondition selector." + } + this.precondition = select + } + + /** + * Registers the pure merge policy consulted on a server-signaled conflict. + * + * The merge returns [MutationConflictResolution.Retry] or + * [MutationConflictResolution.ServerWins] explicitly. When no merge is installed, server-wins + * is the non-removable terminal. + * + * Retrying is bounded: on the third consecutive conflict receipt carrying identical server + * metadata, the intent parks with a normalized `CONFLICT` failure instead of preparing + * another generation. A merge that throws parks the intent as well. + * + * @param merge the pure policy applied to the captured base, this client's value, and the + * recaptured authoritative value + */ + @ExperimentalStoreApi + public fun merge( + merge: ( + base: MutationPresence, + mine: MutationPresence, + theirs: MutationPresence, + ) -> MutationConflictResolution, + ) { + require(this.merge == null) { + "conflicts { } already registered a merge policy." + } + this.merge = merge + } + + internal fun build(): MutationConflictRegistration = + MutationConflictRegistration( + precondition = precondition, + merge = merge, + ) +} + +/** The validated conflict policy retained for the engine's conflict pipeline. */ +internal class MutationConflictRegistration( + internal val precondition: ((MutationPreconditionCandidate) -> StoreMeta?)?, + internal val merge: ( + ( + base: MutationPresence, + mine: MutationPresence, + theirs: MutationPresence, + ) -> MutationConflictResolution + )?, +) + +/** + * One immutable snapshot of a [MutationStoreBuilder]'s retained configuration. + * + * [sourceOfTruth] and [bookkeeper] are the exact selected-or-default instances forwarded to both + * the delegated core Store and the mutation engine. [applyCoreConfiguration] replays every + * configured core door onto the factory's core builder; unset optional doors are not replayed, so + * core keeps its own defaults for them, while persistence and bookkeeping always install the + * retained instances. [wallClock] is additionally read by the factory so the engine's enqueue + * and failure stamps share the configured clock; when unset, core keeps its internal default and + * the engine uses the mutations-owned system clock. + */ +internal class MutationStoreConfiguration( + private val installFetcher: StoreBuilder.() -> Unit, + internal val sourceOfTruth: SourceOfTruth, + internal val bookkeeper: Bookkeeper, + private val telemetry: StoreTelemetry?, + internal val wallClock: WallClock?, + private val freshnessValidator: FreshnessValidator?, + private val maxIdleKeys: Int?, + internal val conflicts: MutationConflictRegistration?, + internal val journalStorage: MutationJournalStorage, +) { + internal fun applyCoreConfiguration(builder: StoreBuilder) { + builder.installFetcher() + builder.persistence(sourceOfTruth) + builder.bookkeeper(bookkeeper) + telemetry?.let(builder::telemetry) + wallClock?.let(builder::wallClock) + freshnessValidator?.let(builder::freshnessValidator) + maxIdleKeys?.let(builder::maxIdleKeys) + } +} diff --git a/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutatorRegistry.kt b/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutatorRegistry.kt new file mode 100644 index 000000000..563b4de52 --- /dev/null +++ b/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutatorRegistry.kt @@ -0,0 +1,239 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.mutations + +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey + +internal class MutatorRegistryOwnership + +/** The fixed args-codec version used by every `delete` registration. */ +internal const val MUTATION_DELETE_ARGS_VERSION: Int = 1 + +/** + * The one deliberate args-codec specialization: Store6 owns the `delete` codec at fixed + * version 1, and its encoding is exactly an empty byte array. Durable delete rows with another + * args version or non-empty args bytes are a `CODEC` failure; this codec rejects them at decode + * so the engine normalizes the throw into a `CODEC` failure record. + */ +internal object MutationUnitArgsCodec : MutationCodec { + override fun encode(value: Unit): ByteArray = ByteArray(0) + + override fun decode( + version: Int, + bytes: ByteArray, + ) { + require(version == MUTATION_DELETE_ARGS_VERSION) { + "Delete args are fixed at version $MUTATION_DELETE_ARGS_VERSION; was $version." + } + require(bytes.isEmpty()) { + "Delete args encode zero bytes; received ${bytes.size}." + } + } +} + +/** + * A typed reference to a registered durable mutator. + * + * The three type parameters keep key, value, and args compile-time bound at Kotlin call sites. + */ +@ExperimentalStoreApi +public class MutatorRef internal constructor( + /** The stable mutator storage identity persisted with pending intents. */ + @ExperimentalStoreApi + public val id: String, + internal val ownership: MutatorRegistryOwnership, +) + +/** + * One erased registration: the single place where typed registration lambdas meet the erased + * dispatch used by the journal and engine. Args re-enter their registered type only through + * codec decode or the contained registration-time cast. + */ +internal class MutatorRegistration( + internal val id: String, + + /** The registered args codec/schema version; positive, persisted with each intent. */ + internal val argsVersion: Int, + private val encodeErased: (Any) -> ByteArray, + private val decodeErased: (Int, ByteArray) -> Any, + private val stalesErased: (K, Any) -> StaleSet, + private val projectErased: (MutationPresence, Any) -> MutationPresence?, +) { + /** Encodes [args]; output is defensively copied before any retention. */ + internal fun encodeArgs(args: Any): ByteArray = encodeErased(args).copyOf() + + /** Decodes stored args bytes; input is defensively copied before consumer code. */ + internal fun decodeArgs( + version: Int, + bytes: ByteArray, + ): Any = decodeErased(version, bytes.copyOf()) + + /** Evaluates the pure registered invalidation function. */ + internal fun stales( + key: K, + args: Any, + ): StaleSet = stalesErased(key, args) + + /** Projects [base] through the registered mutator; `null` means decline only. */ + internal fun project( + base: MutationPresence, + args: Any, + ): MutationPresence? = projectErased(base, args) +} + +/** + * The durable mutation projections available to a mutation engine. + * + * Registration is compile-time typed; storage identity is the registered id plus args version. + * Enqueue validates [MutatorRef] ownership against this registry before any journal append. + */ +@ExperimentalStoreApi +public class MutatorRegistry internal constructor( + internal val ownership: MutatorRegistryOwnership, + internal val registrations: Map>, +) + +/** + * Registers typed durable mutators. + * + * Every registration is durable and named: no call-site closure becomes a durable intent. Each + * non-delete mutator supplies an explicit args version and [MutationCodec]; `delete` is the one + * specialization and owns a fixed module-internal version-1 Unit codec encoding zero bytes. + */ +@ExperimentalStoreApi +public class MutatorRegistryBuilder internal constructor() { + private val ownership = MutatorRegistryOwnership() + private val registrations = mutableMapOf>() + private var isBuilt = false + + /** + * Registers the generic mutator shape under [id] and returns the typed reference used to + * enqueue its arguments. + * + * [project] runs synchronously inside Store's Overlay application and may be invoked + * repeatedly or concurrently for different keys. It must be a pure, deterministic, + * non-blocking function of `(base, args)`, and it must not call back into Store. Returning + * `null` means exactly "decline this intent"; a declined head remains pending and + * blocks only its same-effective-key suffix. Deletion is [MutationPresence.Absent], never + * `null`. A thrown failure is contained, reported through `MutationStore.poisoned`, and + * parks the row with a normalized `PROJECTION` failure. + * + * [stales] is the pure declarative invalidation function: equal inputs must produce + * structurally equal [StaleSet]s. Its result is copied, normalized, deduplicated, and sorted + * into immutable effect records before the intent's first push. + * + * [version] is the explicit args codec/schema version persisted with each intent; it must be + * positive. [codec] must be pure and deterministic for that version. + */ + @ExperimentalStoreApi + public fun mutator( + id: String, + version: Int, + codec: MutationCodec, + stales: (K, A) -> StaleSet, + project: (MutationPresence, A) -> MutationPresence?, + ): MutatorRef { + require(!isBuilt) { + "MutatorRegistryBuilder is already built." + } + require(id !in registrations) { + "Mutator id '$id' is already registered." + } + require(version >= 1) { + "Mutator '$id' args version must be positive; was $version." + } + + @Suppress("UNCHECKED_CAST") + val registration = + MutatorRegistration( + id = id, + argsVersion = version, + encodeErased = { args -> codec.encode(args as A) }, + decodeErased = { decodeVersion, bytes -> codec.decode(decodeVersion, bytes) }, + stalesErased = { key, args -> stales(key, args as A) }, + projectErased = { base, args -> project(base, args as A) }, + ) + registrations[id] = registration + return MutatorRef(id, ownership) + } + + /** + * Registers an update mutator: [project] transforms an existing value, and the registration + * declines when the confirmed base is [MutationPresence.Absent]. + */ + @ExperimentalStoreApi + public fun update( + id: String, + version: Int, + codec: MutationCodec, + stales: (K, A) -> StaleSet, + project: (V, A) -> V, + ): MutatorRef = + mutator(id, version, codec, stales) { base, args -> + when (base) { + is MutationPresence.Present -> MutationPresence.Present(project(base.value, args)) + MutationPresence.Absent -> null + } + } + + /** + * Registers a create mutator: [project] builds the new value from args alone and the + * confirmed base is ignored. + */ + @ExperimentalStoreApi + public fun create( + id: String, + version: Int, + codec: MutationCodec, + stales: (K, A) -> StaleSet, + project: (A) -> V, + ): MutatorRef = + mutator(id, version, codec, stales) { _, args -> + MutationPresence.Present(project(args)) + } + + /** + * Registers a delete mutator: it always applies [MutationPresence.Absent] and is drainable. + * It accepts neither a version nor a codec; Store6 owns the fixed version-1 Unit codec whose + * encoding is exactly zero bytes. + */ + @ExperimentalStoreApi + public fun delete( + id: String, + stales: (K, Unit) -> StaleSet, + ): MutatorRef = + mutator(id, MUTATION_DELETE_ARGS_VERSION, MutationUnitArgsCodec, stales) { _, _ -> + MutationPresence.Absent + } + + /** + * Registers an upsert mutator: [project] receives the explicit confirmed + * [MutationPresence] and must return a presence — it cannot decline. + */ + @ExperimentalStoreApi + public fun upsert( + id: String, + version: Int, + codec: MutationCodec, + stales: (K, A) -> StaleSet, + project: (MutationPresence, A) -> MutationPresence, + ): MutatorRef = mutator(id, version, codec, stales, project) + + internal fun build(): MutatorRegistry { + require(!isBuilt) { + "MutatorRegistryBuilder is already built." + } + isBuilt = true + return MutatorRegistry( + ownership = ownership, + registrations = registrations.toMap(), + ) + } +} + +/** Builds a registry of typed durable mutators. */ +@ExperimentalStoreApi +public fun mutatorRegistry( + configure: MutatorRegistryBuilder.() -> Unit, +): MutatorRegistry = MutatorRegistryBuilder().apply(configure).build() diff --git a/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/storage/InMemoryMutationJournalStorage.kt b/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/storage/InMemoryMutationJournalStorage.kt new file mode 100644 index 000000000..625822017 --- /dev/null +++ b/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/storage/InMemoryMutationJournalStorage.kt @@ -0,0 +1,1297 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, +) + +package org.mobilenativefoundation.store6.mutations.storage + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.mutations.MutationFailureKind +import org.mobilenativefoundation.store6.mutations.MutationPresenceState + +/** + * Process-local default [MutationJournalStorage]. + * + * A transaction operates on a private snapshot under one [Mutex]. Normal return replaces the + * committed snapshot; any thrown [Throwable] discards it. This implementation is intentionally + * non-durable across process death, while returning the same instance from a contract-kit reopen + * models reopening the same in-memory journal. + */ +@ExperimentalStoreApi +public class InMemoryMutationJournalStorage : MutationJournalStorage { + private val mutex: Mutex = Mutex() + private var committed: JournalState = JournalState() + + override suspend fun transaction(block: (MutationJournalTransaction) -> R): R = + mutex.withLock { + val working = committed.mutableCopy() + val transaction = InMemoryTransaction(working) + try { + val result = block(transaction) + transaction.validateFinalState() + transaction.close() + committed = working + result + } catch (failure: Throwable) { + transaction.close() + throw failure + } + } +} + +private class InMemoryTransaction( + private val state: JournalState, +) : MutationJournalTransaction { + private var active: Boolean = true + private val clientTransitions: MutableList = mutableListOf() + private val executionTransitions: MutableList = mutableListOf() + private val aliasTransitions: MutableList = mutableListOf() + private val tombstoneTransitions: MutableList = mutableListOf() + + fun close() { + active = false + } + + override fun client(clientId: String): MutationClientRecord? { + requireActive() + return state.clients[clientId]?.freshCopy() + } + + override fun intents(clientId: String): List { + requireActive() + return state.intents.entries + .asSequence() + .filter { it.key.clientId == clientId } + .sortedBy { it.key.sequence } + .map { it.value.freshCopy() } + .toList() + } + + override fun executions(clientId: String): List { + requireActive() + return state.executions.entries + .asSequence() + .filter { it.key.clientId == clientId } + .sortedBy { it.key.sequence } + .map { it.value.freshCopy() } + .toList() + } + + override fun attempts(clientId: String): List { + requireActive() + return state.attempts.entries + .asSequence() + .filter { it.key.clientId == clientId } + .sortedWith(compareBy({ it.key.sequence }, { it.key.generation })) + .map { it.value.freshCopy() } + .toList() + } + + override fun acks(clientId: String): List { + requireActive() + return state.acks.entries + .asSequence() + .filter { it.key.clientId == clientId } + .sortedWith(compareBy({ it.key.sequence }, { it.key.generation })) + .map { it.value.freshCopy() } + .toList() + } + + override fun failures(clientId: String): List { + requireActive() + return state.failures.values + .asSequence() + .filter { it.clientId == clientId } + .sortedBy { it.failureId } + .map { it.freshCopy() } + .toList() + } + + override fun effects(clientId: String): List { + requireActive() + return state.effects.entries + .asSequence() + .filter { it.key.clientId == clientId } + .sortedWith(compareBy({ it.key.sequence }, { it.key.index })) + .map { it.value.freshCopy() } + .toList() + } + + override fun aliases(): List { + requireActive() + return state.aliases.entries + .sortedWith(compareBy({ it.key.namespace }, { it.key.canonicalId })) + .map { it.value.freshCopy() } + } + + override fun tombstones(): List { + requireActive() + return state.tombstones.entries + .sortedWith( + compareBy( + { it.key.namespace }, + { it.key.canonicalId }, + { it.key.createdByClientId }, + { it.key.createdBySequence }, + ), + ) + .map { it.value.freshCopy() } + } + + override fun insertClient(record: MutationClientRecord) { + requireActive() + require(record.clientId !in state.clients) { "client already exists: ${record.clientId}" } + require( + record.recordVersion == 1 && + record.lastAllocatedSequence == 0L && + record.retiredThroughSequence == 0L && + record.serverConfirmedRetiredThroughSequence == 0L, + ) { "a new client must start at version 1 with zero allocation and retirement prefixes" } + state.clients[record.clientId] = record.freshCopy() + } + + override fun advanceClient(record: MutationClientRecord) { + requireActive() + val previous = requireNotNull(state.clients[record.clientId]) { + "client does not exist: ${record.clientId}" + } + require(record.recordVersion == previous.recordVersion) { "recordVersion is immutable" } + require(record.createdAt == previous.createdAt) { "createdAt is immutable" } + require(record.lastAllocatedSequence >= previous.lastAllocatedSequence) { + "lastAllocatedSequence cannot regress" + } + require(record.retiredThroughSequence >= previous.retiredThroughSequence) { + "retiredThroughSequence cannot regress" + } + require( + record.serverConfirmedRetiredThroughSequence == + previous.serverConfirmedRetiredThroughSequence, + ) { "checkpoint confirmation advances only through confirmRetiredThrough" } + clientTransitions += ClientTransition(previous, record) + state.clients[record.clientId] = record.freshCopy() + } + + override fun confirmRetiredThrough( + clientId: String, + requestedThroughSequence: Long, + serverConfirmedThroughSequence: Long, + ): MutationClientRecord { + requireActive() + val previous = requireNotNull(state.clients[clientId]) { "client does not exist: $clientId" } + require(requestedThroughSequence >= 0L) { "requested checkpoint must be non-negative" } + require(requestedThroughSequence <= previous.retiredThroughSequence) { + "requested checkpoint cannot exceed the local retirement prefix" + } + require(serverConfirmedThroughSequence <= requestedThroughSequence) { + "server confirmation cannot exceed the exact checkpoint request" + } + require( + serverConfirmedThroughSequence >= previous.serverConfirmedRetiredThroughSequence, + ) { "server confirmation cannot regress" } + val advanced = + MutationClientRecord( + recordVersion = previous.recordVersion, + clientId = previous.clientId, + lastAllocatedSequence = previous.lastAllocatedSequence, + retiredThroughSequence = previous.retiredThroughSequence, + serverConfirmedRetiredThroughSequence = serverConfirmedThroughSequence, + createdAt = previous.createdAt, + ) + state.clients[clientId] = advanced + return advanced.freshCopy() + } + + override fun insertIntent( + recordVersion: Int, + clientId: String, + clientSequence: Long, + mutationId: String, + namespace: String, + canonicalId: String, + mutatorId: String, + mutatorVersion: Int, + argsBlob: ByteArray, + idempotencyRoot: String, + createdAt: Long, + ): MutationIntentRecord { + requireActive() + val client = requireNotNull(state.clients[clientId]) { "client does not exist: $clientId" } + require(clientSequence > client.retiredThroughSequence) { + "intent sequence must be newer than the retired client prefix" + } + require(clientSequence <= client.lastAllocatedSequence) { + "intent sequence exceeds the allocated client sequence" + } + val key = IntentKey(clientId, clientSequence) + require(key !in state.intents) { "intent sequence already exists: $clientSequence" } + require(state.intents.values.none { it.clientId == clientId && it.mutationId == mutationId }) { + "mutationId already exists for client: $mutationId" + } + require(state.intents.values.none { it.idempotencyRoot == idempotencyRoot }) { + "idempotencyRoot already exists: $idempotencyRoot" + } + val record = + MutationIntentRecord( + rowId = state.nextIntentRowId, + recordVersion = recordVersion, + clientId = clientId, + clientSequence = clientSequence, + mutationId = mutationId, + namespace = namespace, + canonicalId = canonicalId, + mutatorId = mutatorId, + mutatorVersion = mutatorVersion, + argsBlob = argsBlob, + idempotencyRoot = idempotencyRoot, + createdAt = createdAt, + ) + state.nextIntentRowId += 1L + state.intents[key] = record + return record.freshCopy() + } + + override fun insertExecution(record: MutationExecutionRecord) { + requireActive() + val key = IntentKey(record.clientId, record.clientSequence) + require(key in state.intents) { "execution requires an intent" } + require(key !in state.executions) { "execution already exists" } + require(record.phase == MutationExecutionPhase.UNPREPARED) { + "new execution must start UNPREPARED" + } + state.executions[key] = record.freshCopy() + } + + override fun advanceExecution(record: MutationExecutionRecord) { + requireActive() + val key = IntentKey(record.clientId, record.clientSequence) + val previous = requireNotNull(state.executions[key]) { "execution does not exist" } + if (previous.sameValueAs(record)) return + + require(record.currentGeneration >= previous.currentGeneration) { + "currentGeneration cannot regress" + } + require(record.attempt >= 0) { "attempt cannot be negative" } + val activeFailureKind = + if (record.phase == MutationExecutionPhase.PARKED) { + val activeFailureId = requireNotNull(record.activeFailureId) + val failure = requireNotNull(state.failures[activeFailureId]) { + "PARKED requires its active failure row" + } + require( + failure.clientId == record.clientId && + failure.clientSequence == record.clientSequence && + failure.generation == record.currentGeneration, + ) { "active failure identity does not match the execution" } + failure.kind + } else { + null + } + requireExecutionTransition(previous, record, activeFailureKind) + if (record.currentGeneration > 0) { + require(AttemptKey(record.clientId, record.clientSequence, record.currentGeneration) in state.attempts) { + "execution generation requires an immutable attempt row" + } + } + val generationContinuation = + previous.phase == MutationExecutionPhase.REFRESH_REQUIRED && + record.phase == MutationExecutionPhase.READY && + record.currentGeneration == previous.currentGeneration + 1 + if (generationContinuation) { + val previousAttempt = + requireNotNull( + state.attempts[ + AttemptKey( + record.clientId, + record.clientSequence, + previous.currentGeneration, + ) + ], + ) { "generation continuation requires the previous immutable attempt" } + val nextAttempt = + requireNotNull( + state.attempts[ + AttemptKey( + record.clientId, + record.clientSequence, + record.currentGeneration, + ) + ], + ) { "generation continuation requires the next immutable attempt" } + require(isUniqueNamespaceOwner(previous)) { + "generation continuation requires the previous unique namespace owner" + } + requireNamespaceAuthorityAvailable(record) + require( + previousAttempt.effectiveNamespace == nextAttempt.effectiveNamespace && + previousAttempt.effectiveCanonicalId == nextAttempt.effectiveCanonicalId, + ) { "generation continuation must preserve exact effective identity" } + } + if (record.phase == MutationExecutionPhase.ACKED) { + require(AttemptKey(record.clientId, record.clientSequence, record.currentGeneration) in state.acks) { + "ACKED requires its matching acknowledgement" + } + } + if (record.phase == MutationExecutionPhase.RETIRED) { + require( + state.effects.values.none { + it.clientId == record.clientId && + it.clientSequence == record.clientSequence && + it.disposition == MutationEffectDisposition.PENDING + }, + ) { "an execution cannot retire with a pending effect" } + } + if ( + previous.phase == MutationExecutionPhase.READY && + record.phase == MutationExecutionPhase.INFLIGHT + ) { + requireNamespaceAuthorityAvailable(record) + } + val wasUniqueNamespaceOwner = + previous.phase == MutationExecutionPhase.EFFECTS_PENDING && + record.phase == MutationExecutionPhase.RETIRED && + isUniqueNamespaceOwner(previous) + executionTransitions += + ExecutionTransition( + previous = previous, + next = record, + wasUniqueNamespaceOwner = wasUniqueNamespaceOwner, + ) + state.executions[key] = record.freshCopy() + } + + override fun insertAttempt(record: MutationAttemptRecord) { + requireActive() + val intentKey = IntentKey(record.clientId, record.clientSequence) + require(intentKey in state.intents) { "attempt requires an intent" } + val key = AttemptKey(record.clientId, record.clientSequence, record.generation) + require(key !in state.attempts) { "attempt generation already exists" } + require( + state.attempts.values.none { + it.generationIdempotencyKey == record.generationIdempotencyKey + }, + ) { "generationIdempotencyKey already exists" } + require(record.conflictMetaPresent == null) { "a new attempt cannot contain a conflict receipt" } + state.attempts[key] = record.freshCopy() + } + + override fun recordConflictReceipt(record: MutationAttemptRecord) { + requireActive() + val key = AttemptKey(record.clientId, record.clientSequence, record.generation) + val previous = requireNotNull(state.attempts[key]) { "attempt does not exist" } + require(previous.samePreparationAs(record)) { "attempt preparation fields are immutable" } + require(record.conflictMetaPresent != null && record.conflictReceivedAt != null) { + "conflict receipt is incomplete" + } + if (previous.sameValueAs(record)) return + require(previous.conflictMetaPresent == null) { "conflict receipt is write-once" } + state.attempts[key] = record.freshCopy() + } + + override fun insertAck(record: MutationAckRecord) { + requireActive() + val attemptKey = AttemptKey(record.clientId, record.clientSequence, record.generation) + val attempt = requireNotNull(state.attempts[attemptKey]) { + "acknowledgement requires its attempt generation" + } + if (record.canonicalTargetNamespace != null) { + require(record.canonicalTargetNamespace == attempt.effectiveNamespace) { + "canonical acknowledgement target cannot cross namespaces" + } + } + val previous = state.acks[attemptKey] + if (previous != null) { + require(previous.sameValueAs(record)) { "acknowledgement is write-once" } + return + } + state.acks[attemptKey] = record.freshCopy() + } + + override fun appendFailure( + clientId: String, + clientSequence: Long, + generation: Int, + kind: MutationFailureKind, + detail: String, + message: String, + occurredAt: Long, + ): MutationFailureRecord { + requireActive() + require(IntentKey(clientId, clientSequence) in state.intents) { "failure requires an intent" } + val record = + MutationFailureRecord( + failureId = state.nextFailureId, + clientId = clientId, + clientSequence = clientSequence, + generation = generation, + kind = kind, + detail = detail.truncateUtf8(FAILURE_DETAIL_MAX_UTF8_BYTES), + message = message.truncateUtf8(FAILURE_MESSAGE_MAX_UTF8_BYTES), + occurredAt = occurredAt, + ) + state.nextFailureId += 1L + state.failures[record.failureId] = record + return record.freshCopy() + } + + override fun insertEffect(record: MutationEffectRecord) { + requireActive() + require(IntentKey(record.clientId, record.clientSequence) in state.intents) { + "effect requires an intent" + } + require(record.disposition == MutationEffectDisposition.PENDING) { + "new effect must start PENDING" + } + val key = EffectKey(record.clientId, record.clientSequence, record.effectIndex) + require(key !in state.effects) { "effect identity already exists" } + state.effects[key] = record.freshCopy() + } + + override fun advanceEffect(record: MutationEffectRecord) { + requireActive() + val key = EffectKey(record.clientId, record.clientSequence, record.effectIndex) + val previous = requireNotNull(state.effects[key]) { "effect does not exist" } + if (previous.sameValueAs(record)) return + require(previous.sameIdentityAndTargetAs(record)) { "effect identity and target are immutable" } + require(previous.disposition == MutationEffectDisposition.PENDING) { + "terminal effect disposition cannot change" + } + require(record.disposition != MutationEffectDisposition.PENDING) { + "effect disposition cannot regress to PENDING" + } + state.effects[key] = record.freshCopy() + } + + override fun insertAlias(record: MutationKeyAliasRecord) { + requireActive() + require(record.state == MutationAliasState.PENDING) { "new alias must start PENDING" } + val key = IdentityKey(record.sourceNamespace, record.sourceCanonicalId) + val previous = state.aliases[key] + if (previous != null) { + require(previous.sameRouteAs(record)) { "alias source cannot be retargeted" } + return + } + require(!wouldCreateAliasCycle(record)) { "alias edge would create a cycle" } + state.aliases[key] = record.freshCopy() + } + + override fun advanceAlias(record: MutationKeyAliasRecord) { + requireActive() + val key = IdentityKey(record.sourceNamespace, record.sourceCanonicalId) + val previous = requireNotNull(state.aliases[key]) { "alias does not exist" } + if (previous.sameValueAs(record)) return + require(previous.sameEdgeAs(record)) { "alias edge and creator are immutable" } + require( + previous.state == MutationAliasState.PENDING && record.state == MutationAliasState.ACTIVE, + ) { "alias state can only advance PENDING to ACTIVE" } + aliasTransitions += AliasTransition(previous, record) + state.aliases[key] = record.freshCopy() + } + + override fun insertTombstone(record: MutationKeyTombstoneRecord) { + requireActive() + require(record.state == MutationTombstoneState.PENDING) { + "new tombstone must start PENDING" + } + val key = record.key() + val previous = state.tombstones[key] + if (previous != null) { + require(previous.sameValueAs(record)) { "tombstone generation is immutable" } + return + } + requireNoOtherTombstoneInState(record, MutationTombstoneState.PENDING, excluding = null) + state.tombstones[key] = record.freshCopy() + } + + override fun advanceTombstone(record: MutationKeyTombstoneRecord) { + requireActive() + val key = record.key() + val previous = requireNotNull(state.tombstones[key]) { "tombstone generation does not exist" } + if (previous.sameValueAs(record)) return + require(previous.sameGenerationAs(record)) { "tombstone generation identity is immutable" } + val legal = + (previous.state == MutationTombstoneState.PENDING && + record.state == MutationTombstoneState.ACTIVE) || + (previous.state == MutationTombstoneState.ACTIVE && + record.state == MutationTombstoneState.SUPERSEDED) + require(legal) { "tombstone state must advance PENDING to ACTIVE to SUPERSEDED" } + if ( + previous.state == MutationTombstoneState.ACTIVE && + record.state == MutationTombstoneState.SUPERSEDED + ) { + require(record.activatedAt == previous.activatedAt) { + "tombstone activation timestamp is immutable after activation" + } + if (record.supersededByClientId == previous.createdByClientId) { + require(record.supersededBySequence != previous.createdBySequence) { + "a same-client tombstone successor must be causally distinct" + } + } + } + requireNoOtherTombstoneInState(record, record.state, excluding = key) + tombstoneTransitions += TombstoneTransition(previous, record) + state.tombstones[key] = record.freshCopy() + } + + override fun prune( + clientId: String, + serverConfirmedRetiredThroughSequence: Long, + ) { + requireActive() + val client = requireNotNull(state.clients[clientId]) { "client does not exist: $clientId" } + require(serverConfirmedRetiredThroughSequence >= 0L) { "prune prefix must be non-negative" } + require( + serverConfirmedRetiredThroughSequence <= + client.serverConfirmedRetiredThroughSequence, + ) { "prune prefix exceeds the persisted server-confirmed prefix" } + + state.intents.keys.removeAll { + it.clientId == clientId && it.sequence <= serverConfirmedRetiredThroughSequence + } + state.executions.keys.removeAll { + it.clientId == clientId && it.sequence <= serverConfirmedRetiredThroughSequence + } + state.attempts.keys.removeAll { + it.clientId == clientId && it.sequence <= serverConfirmedRetiredThroughSequence + } + state.acks.keys.removeAll { + it.clientId == clientId && it.sequence <= serverConfirmedRetiredThroughSequence + } + state.failures.entries.removeAll { + it.value.clientId == clientId && + it.value.clientSequence <= serverConfirmedRetiredThroughSequence + } + state.effects.keys.removeAll { + it.clientId == clientId && it.sequence <= serverConfirmedRetiredThroughSequence + } + state.tombstones.entries.removeAll { + it.value.createdByClientId == clientId && + it.value.createdBySequence <= serverConfirmedRetiredThroughSequence && + it.value.state == MutationTombstoneState.SUPERSEDED && + it.value.supersedingIntentIsConfirmed() + } + } + + private fun requireActive() { + check(active) { "mutation journal transaction is no longer active" } + } + + fun validateFinalState() { + requireActive() + val lowerSuccessors = + tombstoneTransitions.filter { transition -> transition.isSameClientLowerSuccessor() } + if (lowerSuccessors.isEmpty()) return + + lowerSuccessors + .groupBy { transition -> + SuccessorKey( + clientId = requireNotNull(transition.next.supersededByClientId), + sequence = requireNotNull(transition.next.supersededBySequence), + ) + }.forEach { (successor, predecessors) -> + validateCausalLowerSuccessor(successor, predecessors) + } + } + + private fun validateCausalLowerSuccessor( + successor: SuccessorKey, + predecessors: List, + ) { + require( + predecessors.all { transition -> + transition.previous.state == MutationTombstoneState.ACTIVE && + transition.next.state == MutationTombstoneState.SUPERSEDED && + transition.previous.activatedAt == transition.next.activatedAt && + transition.next.supersededByClientId == successor.clientId && + transition.next.supersededBySequence == successor.sequence + }, + ) { "a lower causal successor must preserve every ACTIVE predecessor exactly" } + + val retirement = + executionTransitions.singleOrNull { transition -> + transition.previous.clientId == successor.clientId && + transition.previous.clientSequence == successor.sequence && + transition.previous.phase == MutationExecutionPhase.EFFECTS_PENDING && + transition.next.phase == MutationExecutionPhase.RETIRED + } + require(retirement != null && retirement.wasUniqueNamespaceOwner) { + "a lower causal successor must retire from unique EFFECTS_PENDING authority" + } + + val successorExecution = + state.executions[IntentKey(successor.clientId, successor.sequence)] + require( + successorExecution != null && + successorExecution.phase == MutationExecutionPhase.RETIRED && + successorExecution.currentGeneration == retirement.previous.currentGeneration, + ) { "a lower causal successor must commit its exact RETIRED execution" } + + val attemptKey = + AttemptKey( + clientId = successor.clientId, + sequence = successor.sequence, + generation = retirement.previous.currentGeneration, + ) + val attempt = requireNotNull(state.attempts[attemptKey]) { + "a lower causal successor requires its exact attempt" + } + val acknowledgement = requireNotNull(state.acks[attemptKey]) { + "a lower causal successor requires its exact acknowledgement" + } + require( + acknowledgement.authoritativePresence == MutationPresenceState.PRESENT && + acknowledgement.canonicalTargetNamespace != null && + acknowledgement.canonicalTargetId != null, + ) { "a lower causal successor requires a PRESENT canonical acknowledgement" } + require( + state.effects.values.none { effect -> + effect.clientId == successor.clientId && + effect.clientSequence == successor.sequence && + effect.disposition == MutationEffectDisposition.PENDING + }, + ) { "a lower causal successor cannot commit with a pending effect" } + + val aliasTransition = + aliasTransitions.singleOrNull { transition -> + transition.previous.state == MutationAliasState.PENDING && + transition.next.state == MutationAliasState.ACTIVE && + transition.next.createdByClientId == successor.clientId && + transition.next.createdBySequence == successor.sequence && + transition.next.sourceNamespace == attempt.effectiveNamespace && + transition.next.sourceCanonicalId == attempt.effectiveCanonicalId && + transition.next.targetNamespace == acknowledgement.canonicalTargetNamespace && + transition.next.targetCanonicalId == acknowledgement.canonicalTargetId + } + require(aliasTransition != null) { + "a lower causal successor must activate its exact acknowledged PENDING alias" + } + val finalAlias = + state.aliases[ + IdentityKey( + namespace = aliasTransition.next.sourceNamespace, + canonicalId = aliasTransition.next.sourceCanonicalId, + ) + ] + require(finalAlias != null && finalAlias.sameValueAs(aliasTransition.next)) { + "a lower causal successor must commit its activated alias" + } + + val route = + activeRouteFrom( + IdentityKey(attempt.effectiveNamespace, attempt.effectiveCanonicalId), + ) + require( + predecessors.all { transition -> + IdentityKey(transition.previous.namespace, transition.previous.canonicalId) in route + }, + ) { "a lower causal successor route must contain every predecessor" } + + val collapsedTransitions = + tombstoneTransitions.filter { transition -> + transition.previous.state == MutationTombstoneState.ACTIVE && + IdentityKey( + transition.previous.namespace, + transition.previous.canonicalId, + ) in route + } + require( + collapsedTransitions.all { transition -> + transition.next.state == MutationTombstoneState.SUPERSEDED && + transition.next.supersededByClientId == successor.clientId && + transition.next.supersededBySequence == successor.sequence && + transition.previous.activatedAt == transition.next.activatedAt && + state.tombstones[transition.next.key()]?.sameValueAs(transition.next) == true + }, + ) { "every collapsed ACTIVE tombstone must name the exact causal successor" } + require( + state.tombstones.values.none { tombstone -> + tombstone.state == MutationTombstoneState.ACTIVE && + IdentityKey(tombstone.namespace, tombstone.canonicalId) in route + }, + ) { "a lower causal successor must collapse every ACTIVE tombstone in its route" } + + requireTruthfulPrefixAdvance(successor.clientId) + require( + state.executions.values.none { execution -> + execution.clientId == successor.clientId && + execution.ownsNamespaceAuthority() && + namespaceFor(execution) == attempt.effectiveNamespace + }, + ) { "a lower causal successor must release namespace authority in the same commit" } + } + + private fun requireTruthfulPrefixAdvance(clientId: String) { + val prefixTransitions = + clientTransitions.filter { transition -> + transition.previous.clientId == clientId && + transition.next.retiredThroughSequence > + transition.previous.retiredThroughSequence + } + require(prefixTransitions.isNotEmpty()) { + "a lower causal successor must advance the client retirement prefix" + } + val startingPrefix = prefixTransitions.first().previous.retiredThroughSequence + val finalClient = requireNotNull(state.clients[clientId]) + var truthfulPrefix = startingPrefix + while (truthfulPrefix < finalClient.lastAllocatedSequence) { + val next = state.executions[IntentKey(clientId, truthfulPrefix + 1L)] ?: break + if (next.phase != MutationExecutionPhase.RETIRED) break + truthfulPrefix += 1L + } + require( + finalClient.retiredThroughSequence > startingPrefix && + finalClient.retiredThroughSequence == truthfulPrefix, + ) { "a lower causal successor requires an exact truthful retirement-prefix advance" } + } + + private fun requireNamespaceAuthorityAvailable(candidate: MutationExecutionRecord) { + val namespace = namespaceFor(candidate) + require( + state.executions.values.none { execution -> + execution.clientId == candidate.clientId && + execution.clientSequence != candidate.clientSequence && + execution.ownsNamespaceAuthority() && + namespaceFor(execution) == namespace + }, + ) { "namespace authority already has an owner" } + } + + private fun isUniqueNamespaceOwner(candidate: MutationExecutionRecord): Boolean { + if (!candidate.ownsNamespaceAuthority()) return false + val namespace = namespaceFor(candidate) + val owners = + state.executions.values.filter { execution -> + execution.clientId == candidate.clientId && + execution.ownsNamespaceAuthority() && + namespaceFor(execution) == namespace + } + return owners.size == 1 && + owners.single().clientSequence == candidate.clientSequence + } + + private fun namespaceFor(execution: MutationExecutionRecord): String = + requireNotNull( + state.attempts[ + AttemptKey( + execution.clientId, + execution.clientSequence, + execution.currentGeneration, + ) + ], + ) { "namespace authority requires the exact current attempt" }.effectiveNamespace + + private fun activeRouteFrom(source: IdentityKey): Set { + val route = linkedSetOf() + var cursor = source + while (route.add(cursor)) { + val alias = state.aliases[cursor] ?: return route + if (alias.state != MutationAliasState.ACTIVE) return route + cursor = IdentityKey(alias.targetNamespace, alias.targetCanonicalId) + } + throw IllegalArgumentException("an active alias route cannot contain a cycle") + } + + private fun wouldCreateAliasCycle(record: MutationKeyAliasRecord): Boolean { + val source = IdentityKey(record.sourceNamespace, record.sourceCanonicalId) + var cursor = IdentityKey(record.targetNamespace, record.targetCanonicalId) + val visited = mutableSetOf() + while (visited.add(cursor)) { + if (cursor == source) return true + val next = state.aliases[cursor] ?: return false + cursor = IdentityKey(next.targetNamespace, next.targetCanonicalId) + } + return true + } + + private fun requireNoOtherTombstoneInState( + record: MutationKeyTombstoneRecord, + tombstoneState: MutationTombstoneState, + excluding: TombstoneKey?, + ) { + if (tombstoneState == MutationTombstoneState.SUPERSEDED) return + require( + state.tombstones.none { (key, existing) -> + key != excluding && + existing.namespace == record.namespace && + existing.canonicalId == record.canonicalId && + existing.state == tombstoneState + }, + ) { "effective identity already has a $tombstoneState tombstone generation" } + } + + private fun MutationKeyTombstoneRecord.supersedingIntentIsConfirmed(): Boolean { + val successorClientId = supersededByClientId ?: return false + val successorSequence = supersededBySequence ?: return false + val successorClient = this@InMemoryTransaction.state.clients[successorClientId] ?: return false + return successorSequence <= successorClient.serverConfirmedRetiredThroughSequence + } +} + +private class JournalState( + val clients: MutableMap = mutableMapOf(), + val intents: MutableMap = mutableMapOf(), + val executions: MutableMap = mutableMapOf(), + val attempts: MutableMap = mutableMapOf(), + val acks: MutableMap = mutableMapOf(), + val failures: MutableMap = mutableMapOf(), + val effects: MutableMap = mutableMapOf(), + val aliases: MutableMap = mutableMapOf(), + val tombstones: MutableMap = mutableMapOf(), + var nextIntentRowId: Long = 1L, + var nextFailureId: Long = 1L, +) { + fun mutableCopy(): JournalState = + JournalState( + clients = clients.toMutableMap(), + intents = intents.toMutableMap(), + executions = executions.toMutableMap(), + attempts = attempts.toMutableMap(), + acks = acks.toMutableMap(), + failures = failures.toMutableMap(), + effects = effects.toMutableMap(), + aliases = aliases.toMutableMap(), + tombstones = tombstones.toMutableMap(), + nextIntentRowId = nextIntentRowId, + nextFailureId = nextFailureId, + ) +} + +private data class IntentKey( + val clientId: String, + val sequence: Long, +) + +private data class AttemptKey( + val clientId: String, + val sequence: Long, + val generation: Int, +) + +private data class EffectKey( + val clientId: String, + val sequence: Long, + val index: Int, +) + +private data class IdentityKey( + val namespace: String, + val canonicalId: String, +) + +private data class TombstoneKey( + val namespace: String, + val canonicalId: String, + val createdByClientId: String, + val createdBySequence: Long, +) + +private data class SuccessorKey( + val clientId: String, + val sequence: Long, +) + +private data class ClientTransition( + val previous: MutationClientRecord, + val next: MutationClientRecord, +) + +private data class ExecutionTransition( + val previous: MutationExecutionRecord, + val next: MutationExecutionRecord, + val wasUniqueNamespaceOwner: Boolean, +) + +private data class AliasTransition( + val previous: MutationKeyAliasRecord, + val next: MutationKeyAliasRecord, +) + +private data class TombstoneTransition( + val previous: MutationKeyTombstoneRecord, + val next: MutationKeyTombstoneRecord, +) { + fun isSameClientLowerSuccessor(): Boolean = + next.state == MutationTombstoneState.SUPERSEDED && + next.supersededByClientId == previous.createdByClientId && + requireNotNull(next.supersededBySequence) < previous.createdBySequence +} + +private fun MutationExecutionRecord.ownsNamespaceAuthority(): Boolean = + when (phase) { + MutationExecutionPhase.INFLIGHT, + MutationExecutionPhase.REFRESH_REQUIRED, + MutationExecutionPhase.ACKED, + MutationExecutionPhase.EFFECTS_PENDING, + -> true + + MutationExecutionPhase.READY -> attempt > 0 || currentGeneration > 1 + MutationExecutionPhase.UNPREPARED, + MutationExecutionPhase.PARKED, + MutationExecutionPhase.RETIRED, + -> false + } + +private fun requireExecutionTransition( + previous: MutationExecutionRecord, + next: MutationExecutionRecord, + activeFailureKind: MutationFailureKind?, +) { + require(previous.clientId == next.clientId && previous.clientSequence == next.clientSequence) { + "execution identity is immutable" + } + require(next.attempt >= previous.attempt || next.currentGeneration > previous.currentGeneration) { + "completed-attempt count cannot regress within a generation" + } + + val generationAdvanced = next.currentGeneration > previous.currentGeneration + if (generationAdvanced) { + val initialPreparation = + previous.phase == MutationExecutionPhase.UNPREPARED && + previous.currentGeneration == 0 && + next.phase == MutationExecutionPhase.READY && + next.currentGeneration == 1 + val refreshRetry = + previous.phase == MutationExecutionPhase.REFRESH_REQUIRED && + next.phase == MutationExecutionPhase.READY && + next.currentGeneration == previous.currentGeneration + 1 + require( + (initialPreparation || refreshRetry) && next.attempt == 0 && next.lastAttemptAt == null, + ) { "generation may advance only for initial preparation or a g+1 refresh retry" } + } else { + require(next.currentGeneration == previous.currentGeneration) { + "generation cannot change on this phase edge" + } + } + if ( + previous.phase == MutationExecutionPhase.REFRESH_REQUIRED && + next.phase == MutationExecutionPhase.READY + ) { + require(generationAdvanced) { + "REFRESH_REQUIRED must persist generation g+1 before returning to READY" + } + } + + val legalPhaseEdge = + when (previous.phase) { + MutationExecutionPhase.UNPREPARED -> + next.phase == MutationExecutionPhase.READY || + next.phase == MutationExecutionPhase.PARKED + + MutationExecutionPhase.READY -> + next.phase == MutationExecutionPhase.INFLIGHT || + next.phase == MutationExecutionPhase.PARKED + + MutationExecutionPhase.INFLIGHT -> + next.phase == MutationExecutionPhase.READY || + next.phase == MutationExecutionPhase.REFRESH_REQUIRED || + next.phase == MutationExecutionPhase.ACKED || + next.phase == MutationExecutionPhase.PARKED + + MutationExecutionPhase.REFRESH_REQUIRED -> + next.phase == MutationExecutionPhase.READY || + next.phase == MutationExecutionPhase.RETIRED || + next.phase == MutationExecutionPhase.PARKED + + MutationExecutionPhase.ACKED -> + next.phase == MutationExecutionPhase.EFFECTS_PENDING + + MutationExecutionPhase.EFFECTS_PENDING -> + next.phase == MutationExecutionPhase.RETIRED + + MutationExecutionPhase.PARKED, + MutationExecutionPhase.RETIRED, + -> false + } + require(legalPhaseEdge) { "illegal execution phase edge ${previous.phase} -> ${next.phase}" } + + if (!generationAdvanced) { + val preservesPreTransportAttemptFacts = + previous.phase == MutationExecutionPhase.INFLIGHT && + next.phase == MutationExecutionPhase.PARKED && + next.attempt == previous.attempt && + next.lastAttemptAt == previous.lastAttemptAt && + ( + activeFailureKind == MutationFailureKind.IDENTITY || + activeFailureKind == MutationFailureKind.CODEC + ) + when { + preservesPreTransportAttemptFacts -> Unit + + previous.phase == MutationExecutionPhase.INFLIGHT -> { + require(next.attempt == previous.attempt + 1) { + "a completed INFLIGHT transition advances the attempt count once" + } + require(next.lastAttemptAt != null) { + "a completed INFLIGHT transition records lastAttemptAt" + } + } + + else -> { + require(next.attempt == previous.attempt) { + "attempt count may change only when an INFLIGHT invocation completes" + } + require(next.lastAttemptAt == previous.lastAttemptAt) { + "lastAttemptAt may change only when an INFLIGHT invocation completes" + } + } + } + } +} + +private fun MutationClientRecord.freshCopy(): MutationClientRecord = + MutationClientRecord( + recordVersion = recordVersion, + clientId = clientId, + lastAllocatedSequence = lastAllocatedSequence, + retiredThroughSequence = retiredThroughSequence, + serverConfirmedRetiredThroughSequence = serverConfirmedRetiredThroughSequence, + createdAt = createdAt, + ) + +private fun MutationIntentRecord.freshCopy(): MutationIntentRecord = + MutationIntentRecord( + rowId = rowId, + recordVersion = recordVersion, + clientId = clientId, + clientSequence = clientSequence, + mutationId = mutationId, + namespace = namespace, + canonicalId = canonicalId, + mutatorId = mutatorId, + mutatorVersion = mutatorVersion, + argsBlob = argsBlob, + idempotencyRoot = idempotencyRoot, + createdAt = createdAt, + ) + +private fun MutationExecutionRecord.freshCopy(): MutationExecutionRecord = + MutationExecutionRecord( + clientId = clientId, + clientSequence = clientSequence, + phase = phase, + currentGeneration = currentGeneration, + attempt = attempt, + lastAttemptAt = lastAttemptAt, + activeFailureId = activeFailureId, + retiredAt = retiredAt, + ) + +private fun MutationAttemptRecord.freshCopy(): MutationAttemptRecord = + MutationAttemptRecord( + clientId = clientId, + clientSequence = clientSequence, + generation = generation, + effectiveNamespace = effectiveNamespace, + effectiveCanonicalId = effectiveCanonicalId, + valueCodecVersion = valueCodecVersion, + basePresence = basePresence, + baseBlob = baseBlob, + minePresence = minePresence, + mineBlob = mineBlob, + preconditionMetaPresent = preconditionMetaPresent, + preconditionWrittenAt = preconditionWrittenAt, + preconditionEtag = preconditionEtag, + advertisedRetiredThroughSequence = advertisedRetiredThroughSequence, + generationIdempotencyKey = generationIdempotencyKey, + preparedAt = preparedAt, + conflictMetaPresent = conflictMetaPresent, + conflictWrittenAt = conflictWrittenAt, + conflictEtag = conflictEtag, + conflictReceivedAt = conflictReceivedAt, + ) + +private fun MutationAckRecord.freshCopy(): MutationAckRecord = + MutationAckRecord( + clientId = clientId, + clientSequence = clientSequence, + generation = generation, + authoritativePresence = authoritativePresence, + authoritativeBlob = authoritativeBlob, + valueCodecVersion = valueCodecVersion, + etag = etag, + canonicalTargetNamespace = canonicalTargetNamespace, + canonicalTargetId = canonicalTargetId, + receivedAt = receivedAt, + ) + +private fun MutationFailureRecord.freshCopy(): MutationFailureRecord = + MutationFailureRecord( + failureId = failureId, + clientId = clientId, + clientSequence = clientSequence, + generation = generation, + kind = kind, + detail = detail, + message = message, + occurredAt = occurredAt, + ) + +private fun MutationEffectRecord.freshCopy(): MutationEffectRecord = + MutationEffectRecord( + clientId = clientId, + clientSequence = clientSequence, + effectIndex = effectIndex, + kind = kind, + namespace = namespace, + canonicalId = canonicalId, + createdAt = createdAt, + disposition = disposition, + completedAt = completedAt, + ) + +private fun MutationKeyAliasRecord.freshCopy(): MutationKeyAliasRecord = + MutationKeyAliasRecord( + sourceNamespace = sourceNamespace, + sourceCanonicalId = sourceCanonicalId, + targetNamespace = targetNamespace, + targetCanonicalId = targetCanonicalId, + state = state, + createdByClientId = createdByClientId, + createdBySequence = createdBySequence, + createdAt = createdAt, + activatedAt = activatedAt, + ) + +private fun MutationKeyTombstoneRecord.freshCopy(): MutationKeyTombstoneRecord = + MutationKeyTombstoneRecord( + namespace = namespace, + canonicalId = canonicalId, + createdByClientId = createdByClientId, + createdBySequence = createdBySequence, + state = state, + createdAt = createdAt, + activatedAt = activatedAt, + supersededByClientId = supersededByClientId, + supersededBySequence = supersededBySequence, + supersededAt = supersededAt, + ) + +private fun MutationExecutionRecord.sameValueAs(other: MutationExecutionRecord): Boolean = + clientId == other.clientId && + clientSequence == other.clientSequence && + phase == other.phase && + currentGeneration == other.currentGeneration && + attempt == other.attempt && + lastAttemptAt == other.lastAttemptAt && + activeFailureId == other.activeFailureId && + retiredAt == other.retiredAt + +private fun MutationAttemptRecord.samePreparationAs(other: MutationAttemptRecord): Boolean = + clientId == other.clientId && + clientSequence == other.clientSequence && + generation == other.generation && + effectiveNamespace == other.effectiveNamespace && + effectiveCanonicalId == other.effectiveCanonicalId && + valueCodecVersion == other.valueCodecVersion && + basePresence == other.basePresence && + baseBlob.contentEqualsNullable(other.baseBlob) && + minePresence == other.minePresence && + mineBlob.contentEqualsNullable(other.mineBlob) && + preconditionMetaPresent == other.preconditionMetaPresent && + preconditionWrittenAt == other.preconditionWrittenAt && + preconditionEtag == other.preconditionEtag && + advertisedRetiredThroughSequence == other.advertisedRetiredThroughSequence && + generationIdempotencyKey == other.generationIdempotencyKey && + preparedAt == other.preparedAt + +private fun MutationAttemptRecord.sameValueAs(other: MutationAttemptRecord): Boolean = + samePreparationAs(other) && + conflictMetaPresent == other.conflictMetaPresent && + conflictWrittenAt == other.conflictWrittenAt && + conflictEtag == other.conflictEtag && + conflictReceivedAt == other.conflictReceivedAt + +private fun MutationAckRecord.sameValueAs(other: MutationAckRecord): Boolean = + clientId == other.clientId && + clientSequence == other.clientSequence && + generation == other.generation && + authoritativePresence == other.authoritativePresence && + authoritativeBlob.contentEqualsNullable(other.authoritativeBlob) && + valueCodecVersion == other.valueCodecVersion && + etag == other.etag && + canonicalTargetNamespace == other.canonicalTargetNamespace && + canonicalTargetId == other.canonicalTargetId + +private fun MutationEffectRecord.sameIdentityAndTargetAs(other: MutationEffectRecord): Boolean = + clientId == other.clientId && + clientSequence == other.clientSequence && + effectIndex == other.effectIndex && + kind == other.kind && + namespace == other.namespace && + canonicalId == other.canonicalId && + createdAt == other.createdAt + +private fun MutationEffectRecord.sameValueAs(other: MutationEffectRecord): Boolean = + sameIdentityAndTargetAs(other) && + disposition == other.disposition && + completedAt == other.completedAt + +private fun MutationKeyAliasRecord.sameEdgeAs(other: MutationKeyAliasRecord): Boolean = + sourceNamespace == other.sourceNamespace && + sourceCanonicalId == other.sourceCanonicalId && + targetNamespace == other.targetNamespace && + targetCanonicalId == other.targetCanonicalId && + createdByClientId == other.createdByClientId && + createdBySequence == other.createdBySequence && + createdAt == other.createdAt + +private fun MutationKeyAliasRecord.sameRouteAs(other: MutationKeyAliasRecord): Boolean = + sourceNamespace == other.sourceNamespace && + sourceCanonicalId == other.sourceCanonicalId && + targetNamespace == other.targetNamespace && + targetCanonicalId == other.targetCanonicalId + +private fun MutationKeyAliasRecord.sameValueAs(other: MutationKeyAliasRecord): Boolean = + sameEdgeAs(other) && state == other.state && activatedAt == other.activatedAt + +private fun MutationKeyTombstoneRecord.key(): TombstoneKey = + TombstoneKey(namespace, canonicalId, createdByClientId, createdBySequence) + +private fun MutationKeyTombstoneRecord.sameGenerationAs( + other: MutationKeyTombstoneRecord, +): Boolean = + namespace == other.namespace && + canonicalId == other.canonicalId && + createdByClientId == other.createdByClientId && + createdBySequence == other.createdBySequence && + createdAt == other.createdAt + +private fun MutationKeyTombstoneRecord.sameValueAs(other: MutationKeyTombstoneRecord): Boolean = + sameGenerationAs(other) && + state == other.state && + activatedAt == other.activatedAt && + supersededByClientId == other.supersededByClientId && + supersededBySequence == other.supersededBySequence && + supersededAt == other.supersededAt + +private fun ByteArray?.contentEqualsNullable(other: ByteArray?): Boolean = + when { + this == null -> other == null + other == null -> false + else -> contentEquals(other) + } + +private fun String.truncateUtf8(maxBytes: Int): String { + if (encodeToByteArray().size <= maxBytes) return this + val result = StringBuilder() + var index = 0 + while (index < length) { + val current = this[index] + val width = + if ( + current in '\uD800'..'\uDBFF' && + index + 1 < length && + this[index + 1] in '\uDC00'..'\uDFFF' + ) { + 2 + } else { + 1 + } + val next = substring(index, index + width) + if ((result.toString() + next).encodeToByteArray().size > maxBytes) break + result.append(next) + index += width + } + return result.toString() +} diff --git a/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/storage/MutationJournalRecords.kt b/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/storage/MutationJournalRecords.kt new file mode 100644 index 000000000..553e242fd --- /dev/null +++ b/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/storage/MutationJournalRecords.kt @@ -0,0 +1,422 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.mutations.storage + +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.mutations.MutationFailureKind +import org.mobilenativefoundation.store6.mutations.MutationPresenceState + +/** + * Stable persisted execution-phase names. Storage implementations persist `name`, never ordinal. + * + * [PARKED] and [RETIRED] are the two terminal phases: a parked execution carries an active failure + * id and appears only in dead letters, and a retired one carries its retirement time and appears in + * neither inspection API. The six nonterminal phases are the durable form of the public + * [org.mobilenativefoundation.store6.mutations.MutationPendingState] vocabulary, which documents + * the mapping. + */ +@ExperimentalStoreApi +public enum class MutationExecutionPhase { + UNPREPARED, + READY, + INFLIGHT, + REFRESH_REQUIRED, + ACKED, + EFFECTS_PENDING, + PARKED, + RETIRED, +} + +/** Stable persisted invalidation-effect target names. */ +@ExperimentalStoreApi +public enum class MutationEffectKind { + KEY, + NAMESPACE, +} + +/** Stable persisted invalidation-effect disposition names. */ +@ExperimentalStoreApi +public enum class MutationEffectDisposition { + PENDING, + APPLIED, + SKIPPED, +} + +/** Stable persisted alias-edge state names. */ +@ExperimentalStoreApi +public enum class MutationAliasState { + PENDING, + ACTIVE, +} + +/** Stable persisted tombstone-generation state names. */ +@ExperimentalStoreApi +public enum class MutationTombstoneState { + PENDING, + ACTIVE, + SUPERSEDED, +} + +/** Durable allocation and retirement high-water state for one mutation client. */ +@ExperimentalStoreApi +public class MutationClientRecord( + @ExperimentalStoreApi public val recordVersion: Int, + @ExperimentalStoreApi public val clientId: String, + @ExperimentalStoreApi public val lastAllocatedSequence: Long, + @ExperimentalStoreApi public val retiredThroughSequence: Long, + @ExperimentalStoreApi public val serverConfirmedRetiredThroughSequence: Long, + @ExperimentalStoreApi public val createdAt: Long, +) { + init { + require(recordVersion > 0) { "recordVersion must be positive" } + require(serverConfirmedRetiredThroughSequence >= 0L) { + "server-confirmed retirement prefix must be non-negative" + } + require(serverConfirmedRetiredThroughSequence <= retiredThroughSequence) { + "server-confirmed retirement prefix cannot exceed the local prefix" + } + require(retiredThroughSequence <= lastAllocatedSequence) { + "local retirement prefix cannot exceed the allocated sequence" + } + } +} + +/** Immutable durable intent. `argsBlob` is copied on construction and every read. */ +@ExperimentalStoreApi +public class MutationIntentRecord( + @ExperimentalStoreApi public val rowId: Long, + @ExperimentalStoreApi public val recordVersion: Int, + @ExperimentalStoreApi public val clientId: String, + @ExperimentalStoreApi public val clientSequence: Long, + @ExperimentalStoreApi public val mutationId: String, + @ExperimentalStoreApi public val namespace: String, + @ExperimentalStoreApi public val canonicalId: String, + @ExperimentalStoreApi public val mutatorId: String, + @ExperimentalStoreApi public val mutatorVersion: Int, + argsBlob: ByteArray, + @ExperimentalStoreApi public val idempotencyRoot: String, + @ExperimentalStoreApi public val createdAt: Long, +) { + private val argsBlobValue: ByteArray = argsBlob.copyOf() + + /** Defensively copied encoded mutator arguments. */ + @ExperimentalStoreApi + public val argsBlob: ByteArray + get() = argsBlobValue.copyOf() + + init { + require(recordVersion > 0) { "recordVersion must be positive" } + require(clientSequence > 0L) { "clientSequence must be positive" } + require(mutatorVersion > 0) { "mutatorVersion must be positive" } + } +} + +/** Mutable durable execution state for exactly one [MutationIntentRecord]. */ +@ExperimentalStoreApi +public class MutationExecutionRecord( + @ExperimentalStoreApi public val clientId: String, + + /** The owning intent's durable per-client sequence; always positive. */ + @ExperimentalStoreApi public val clientSequence: Long, + @ExperimentalStoreApi public val phase: MutationExecutionPhase, + + /** + * The semantic generation this execution references; never negative. It is zero only in + * [MutationExecutionPhase.UNPREPARED] and in a [MutationExecutionPhase.PARKED] execution that + * never prepared one; every other phase references a prepared generation. + */ + @ExperimentalStoreApi public val currentGeneration: Int, + + /** + * Completed network attempts for `currentGeneration`; never negative, and zero while + * `currentGeneration` is zero. + */ + @ExperimentalStoreApi public val attempt: Int, + + /** + * When the latest network attempt completed, in Unix epoch milliseconds. Non-null exactly + * when `attempt` is nonzero. + */ + @ExperimentalStoreApi public val lastAttemptAt: Long?, + + /** + * The [MutationFailureRecord.failureId] retained by a parked execution. Non-null exactly in + * [MutationExecutionPhase.PARKED]. + */ + @ExperimentalStoreApi public val activeFailureId: Long?, + + /** + * When this execution retired, in Unix epoch milliseconds. Non-null exactly in + * [MutationExecutionPhase.RETIRED]. + */ + @ExperimentalStoreApi public val retiredAt: Long?, +) { + init { + require(clientSequence > 0L) { "clientSequence must be positive" } + require(currentGeneration >= 0) { "currentGeneration must be non-negative" } + require(attempt >= 0) { "attempt must be non-negative" } + require((attempt == 0) == (lastAttemptAt == null)) { + "lastAttemptAt must exist exactly when a network attempt has completed" + } + if (currentGeneration == 0) { + require(attempt == 0 && lastAttemptAt == null) { + "generation zero cannot contain completed network-attempt facts" + } + } + require((phase == MutationExecutionPhase.PARKED) == (activeFailureId != null)) { + "activeFailureId must exist exactly in PARKED" + } + require((phase == MutationExecutionPhase.RETIRED) == (retiredAt != null)) { + "retiredAt must exist exactly in RETIRED" + } + if (phase == MutationExecutionPhase.UNPREPARED) { + require(currentGeneration == 0) { "UNPREPARED cannot reference a generation" } + } else if (phase != MutationExecutionPhase.PARKED || currentGeneration != 0) { + require(currentGeneration > 0) { "$phase must reference a prepared generation" } + } + } +} + +/** Immutable semantic attempt generation with a write-once conflict receipt. */ +@ExperimentalStoreApi +public class MutationAttemptRecord( + @ExperimentalStoreApi public val clientId: String, + @ExperimentalStoreApi public val clientSequence: Long, + @ExperimentalStoreApi public val generation: Int, + @ExperimentalStoreApi public val effectiveNamespace: String, + @ExperimentalStoreApi public val effectiveCanonicalId: String, + @ExperimentalStoreApi public val valueCodecVersion: Int, + @ExperimentalStoreApi public val basePresence: MutationPresenceState, + baseBlob: ByteArray?, + @ExperimentalStoreApi public val minePresence: MutationPresenceState, + mineBlob: ByteArray?, + @ExperimentalStoreApi public val preconditionMetaPresent: Boolean, + @ExperimentalStoreApi public val preconditionWrittenAt: Long?, + @ExperimentalStoreApi public val preconditionEtag: String?, + @ExperimentalStoreApi public val advertisedRetiredThroughSequence: Long, + @ExperimentalStoreApi public val generationIdempotencyKey: String, + @ExperimentalStoreApi public val preparedAt: Long, + @ExperimentalStoreApi public val conflictMetaPresent: Boolean?, + @ExperimentalStoreApi public val conflictWrittenAt: Long?, + @ExperimentalStoreApi public val conflictEtag: String?, + @ExperimentalStoreApi public val conflictReceivedAt: Long?, +) { + private val baseBlobValue: ByteArray? = baseBlob?.copyOf() + private val mineBlobValue: ByteArray? = mineBlob?.copyOf() + + /** Defensively copied encoded base value, or null for an absent base. */ + @ExperimentalStoreApi + public val baseBlob: ByteArray? + get() = baseBlobValue?.copyOf() + + /** Defensively copied encoded optimistic value, or null for an absent value. */ + @ExperimentalStoreApi + public val mineBlob: ByteArray? + get() = mineBlobValue?.copyOf() + + init { + require(clientSequence > 0L) { "clientSequence must be positive" } + require(generation > 0) { "generation must be positive" } + require(valueCodecVersion > 0) { "valueCodecVersion must be positive" } + require(advertisedRetiredThroughSequence >= 0L) { + "advertisedRetiredThroughSequence must be non-negative" + } + require((basePresence == MutationPresenceState.PRESENT) == (baseBlobValue != null)) { + "baseBlob presence must match basePresence" + } + require((minePresence == MutationPresenceState.PRESENT) == (mineBlobValue != null)) { + "mineBlob presence must match minePresence" + } + require(preconditionMetaPresent == (preconditionWrittenAt != null)) { + "preconditionWrittenAt presence must match preconditionMetaPresent" + } + if (!preconditionMetaPresent) { + require(preconditionEtag == null) { "preconditionEtag requires metadata" } + } + if (conflictMetaPresent == null) { + require(conflictWrittenAt == null && conflictEtag == null && conflictReceivedAt == null) { + "an absent conflict receipt cannot carry receipt fields" + } + } else { + require(conflictReceivedAt != null) { "a conflict receipt requires conflictReceivedAt" } + require(conflictMetaPresent == (conflictWrittenAt != null)) { + "conflictWrittenAt presence must match conflictMetaPresent" + } + if (!conflictMetaPresent) { + require(conflictEtag == null) { "conflictEtag requires conflict metadata" } + } + } + } +} + +/** Write-once durable acknowledgement for an exact attempt generation. */ +@ExperimentalStoreApi +public class MutationAckRecord( + @ExperimentalStoreApi public val clientId: String, + @ExperimentalStoreApi public val clientSequence: Long, + @ExperimentalStoreApi public val generation: Int, + @ExperimentalStoreApi public val authoritativePresence: MutationPresenceState, + authoritativeBlob: ByteArray?, + @ExperimentalStoreApi public val valueCodecVersion: Int, + @ExperimentalStoreApi public val etag: String?, + @ExperimentalStoreApi public val canonicalTargetNamespace: String?, + @ExperimentalStoreApi public val canonicalTargetId: String?, + @ExperimentalStoreApi public val receivedAt: Long, +) { + private val authoritativeBlobValue: ByteArray? = authoritativeBlob?.copyOf() + + /** Defensively copied server-authoritative value, or null for authoritative absence. */ + @ExperimentalStoreApi + public val authoritativeBlob: ByteArray? + get() = authoritativeBlobValue?.copyOf() + + init { + require(clientSequence > 0L) { "clientSequence must be positive" } + require(generation > 0) { "generation must be positive" } + require(valueCodecVersion > 0) { "valueCodecVersion must be positive" } + require( + (authoritativePresence == MutationPresenceState.PRESENT) == + (authoritativeBlobValue != null), + ) { "authoritativeBlob presence must match authoritativePresence" } + require((canonicalTargetNamespace == null) == (canonicalTargetId == null)) { + "canonical target fields must be both null or both present" + } + if (canonicalTargetNamespace != null) { + require(authoritativePresence == MutationPresenceState.PRESENT) { + "canonical targets require an authoritative present value" + } + } + } +} + +/** Append-only normalized failure evidence. */ +@ExperimentalStoreApi +public class MutationFailureRecord( + @ExperimentalStoreApi public val failureId: Long, + @ExperimentalStoreApi public val clientId: String, + @ExperimentalStoreApi public val clientSequence: Long, + @ExperimentalStoreApi public val generation: Int, + @ExperimentalStoreApi public val kind: MutationFailureKind, + @ExperimentalStoreApi public val detail: String, + @ExperimentalStoreApi public val message: String, + @ExperimentalStoreApi public val occurredAt: Long, +) { + init { + require(clientSequence > 0L) { "clientSequence must be positive" } + require(generation >= 0) { "generation must be non-negative" } + require(detail.encodeToByteArray().size <= FAILURE_DETAIL_MAX_UTF8_BYTES) { + "detail exceeds $FAILURE_DETAIL_MAX_UTF8_BYTES UTF-8 bytes" + } + require(message.encodeToByteArray().size <= FAILURE_MESSAGE_MAX_UTF8_BYTES) { + "message exceeds $FAILURE_MESSAGE_MAX_UTF8_BYTES UTF-8 bytes" + } + } +} + +/** One normalized durable invalidation effect. */ +@ExperimentalStoreApi +public class MutationEffectRecord( + @ExperimentalStoreApi public val clientId: String, + @ExperimentalStoreApi public val clientSequence: Long, + @ExperimentalStoreApi public val effectIndex: Int, + @ExperimentalStoreApi public val kind: MutationEffectKind, + @ExperimentalStoreApi public val namespace: String, + @ExperimentalStoreApi public val canonicalId: String?, + @ExperimentalStoreApi public val createdAt: Long, + @ExperimentalStoreApi public val disposition: MutationEffectDisposition, + @ExperimentalStoreApi public val completedAt: Long?, +) { + init { + require(clientSequence > 0L) { "clientSequence must be positive" } + require(effectIndex >= 0) { "effectIndex must be non-negative" } + require((kind == MutationEffectKind.KEY) == (canonicalId != null)) { + "canonicalId must exist exactly for KEY effects" + } + require((disposition == MutationEffectDisposition.PENDING) == (completedAt == null)) { + "completedAt must be null exactly while an effect is PENDING" + } + } +} + +/** Durable same-namespace redirect edge. */ +@ExperimentalStoreApi +public class MutationKeyAliasRecord( + @ExperimentalStoreApi public val sourceNamespace: String, + @ExperimentalStoreApi public val sourceCanonicalId: String, + @ExperimentalStoreApi public val targetNamespace: String, + @ExperimentalStoreApi public val targetCanonicalId: String, + @ExperimentalStoreApi public val state: MutationAliasState, + @ExperimentalStoreApi public val createdByClientId: String, + @ExperimentalStoreApi public val createdBySequence: Long, + @ExperimentalStoreApi public val createdAt: Long, + @ExperimentalStoreApi public val activatedAt: Long?, +) { + init { + require(createdBySequence > 0L) { "createdBySequence must be positive" } + require(sourceNamespace == targetNamespace) { "alias edges cannot cross namespaces" } + require(sourceCanonicalId != targetCanonicalId) { "self alias edges are not stored" } + require((state == MutationAliasState.ACTIVE) == (activatedAt != null)) { + "activatedAt must exist exactly for ACTIVE aliases" + } + } +} + +/** One durable tombstone generation for an effective identity. */ +@ExperimentalStoreApi +public class MutationKeyTombstoneRecord( + @ExperimentalStoreApi public val namespace: String, + @ExperimentalStoreApi public val canonicalId: String, + @ExperimentalStoreApi public val createdByClientId: String, + @ExperimentalStoreApi public val createdBySequence: Long, + @ExperimentalStoreApi public val state: MutationTombstoneState, + @ExperimentalStoreApi public val createdAt: Long, + @ExperimentalStoreApi public val activatedAt: Long?, + @ExperimentalStoreApi public val supersededByClientId: String?, + @ExperimentalStoreApi public val supersededBySequence: Long?, + @ExperimentalStoreApi public val supersededAt: Long?, +) { + init { + require(createdBySequence > 0L) { "createdBySequence must be positive" } + require((supersededByClientId == null) == (supersededBySequence == null)) { + "superseding identity fields must be both null or both present" + } + if (supersededBySequence != null) { + require(supersededBySequence > 0L) { + "supersededBySequence must identify a positive client sequence" + } + } + when (state) { + MutationTombstoneState.PENDING -> { + require(activatedAt == null) { "PENDING tombstones are not activated" } + require(supersededByClientId == null && supersededAt == null) { + "PENDING tombstones are not superseded" + } + } + + MutationTombstoneState.ACTIVE -> { + require(activatedAt != null) { "ACTIVE tombstones require activatedAt" } + require(supersededByClientId == null && supersededAt == null) { + "ACTIVE tombstones are not superseded" + } + } + + MutationTombstoneState.SUPERSEDED -> { + require(activatedAt != null) { "SUPERSEDED tombstones retain activatedAt" } + require(supersededByClientId != null && supersededAt != null) { + "SUPERSEDED tombstones require a complete successor and timestamp" + } + if (supersededByClientId == createdByClientId) { + require(supersededBySequence != createdBySequence) { + "a same-client superseding intent must be causally distinct" + } + } + // Same-client causal successors may carry a lower positive durable sequence. The + // storage transaction validates that narrow exception against the complete + // authority, acknowledgement, routing, effects, and retirement final state. + } + } + } +} + +internal const val FAILURE_DETAIL_MAX_UTF8_BYTES: Int = 128 +internal const val FAILURE_MESSAGE_MAX_UTF8_BYTES: Int = 1_024 diff --git a/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/storage/MutationJournalStorage.kt b/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/storage/MutationJournalStorage.kt new file mode 100644 index 000000000..76bb9f01b --- /dev/null +++ b/store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/storage/MutationJournalStorage.kt @@ -0,0 +1,176 @@ +package org.mobilenativefoundation.store6.mutations.storage + +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.mutations.MutationFailureKind + +/** + * Durable mutation-journal storage seam. + * + * This package freezes nine logical records. Implementations persist enum names, never ordinals; + * store every time as Unix epoch milliseconds; copy every byte array on entry and delivery; and + * preserve the transition, uniqueness, ordering, and pruning rules enforced by the testing + * contract kit. + * + * [transaction] is the single generic unit-of-work door. Normal return commits every operation; + * any thrown [Throwable], including cancellation, commits none and propagates unchanged. The + * callback is deliberately non-suspending so codec, resolver, transport, and policy work cannot + * suspend while a journal transaction is held. A transaction handle is invalid after the callback + * returns. + * + * A committed change that alters projection membership or routing is only the durable half of the + * mutation accepted-state handoff. The engine synchronously publishes the corresponding overlay or + * alias revision after commit and before external completion or cancellation becomes observable. + */ +// docs:snippet:mutations-journal-storage-interface +@ExperimentalStoreApi +@SubclassOptInRequired(DelicateStoreApi::class) +public interface MutationJournalStorage { + /** Runs [block] as one serializable, exception-atomic unit of work. */ + public suspend fun transaction(block: (MutationJournalTransaction) -> R): R +} +// docs:snippet:end + +/** Scoped reads and mutations available only inside [MutationJournalStorage.transaction]. */ +@ExperimentalStoreApi +@SubclassOptInRequired(DelicateStoreApi::class) +public interface MutationJournalTransaction { + /** Returns the client row, or null when this journal has not initialized [clientId]. */ + public fun client(clientId: String): MutationClientRecord? + + /** Returns retained intents ordered by client sequence. */ + public fun intents(clientId: String): List + + /** Returns retained executions ordered by client sequence. */ + public fun executions(clientId: String): List + + /** Returns retained attempts ordered by client sequence then generation. */ + public fun attempts(clientId: String): List + + /** Returns retained acknowledgements ordered by client sequence then generation. */ + public fun acks(clientId: String): List + + /** Returns append-only failures ordered by storage-local failure ID. */ + public fun failures(clientId: String): List + + /** Returns effects ordered by client sequence then effect index. */ + public fun effects(clientId: String): List + + /** Returns every retained alias edge ordered by source identity. */ + public fun aliases(): List + + /** Returns every retained tombstone generation in deterministic identity order. */ + public fun tombstones(): List + + /** + * Inserts a new version-1 client row with allocation and both retirement prefixes equal to + * zero. Later hydrated states are represented only by monotonic advances of that initial row. + */ + public fun insertClient(record: MutationClientRecord) + + /** + * Monotonically advances allocation and local retirement fields. The persisted server-confirmed + * field must remain unchanged; only [confirmRetiredThrough] may advance it. + */ + public fun advanceClient(record: MutationClientRecord) + + /** + * Monotonically records a checkpoint response without accepting a value greater than the + * exact [requestedThroughSequence] sent to the backend. + */ + public fun confirmRetiredThrough( + clientId: String, + requestedThroughSequence: Long, + serverConfirmedThroughSequence: Long, + ): MutationClientRecord + + /** Allocates a storage-local row ID, inserts an immutable intent, and returns its copied row. */ + public fun insertIntent( + recordVersion: Int, + clientId: String, + clientSequence: Long, + mutationId: String, + namespace: String, + canonicalId: String, + mutatorId: String, + mutatorVersion: Int, + argsBlob: ByteArray, + idempotencyRoot: String, + createdAt: Long, + ): MutationIntentRecord + + /** Inserts the one execution row paired with an intent. */ + public fun insertExecution(record: MutationExecutionRecord) + + /** + * Applies an execution-state transition. + * + * The first `READY -> INFLIGHT` transition acquires the derived durable authority for the + * exact attempt's `(clientId, effectiveNamespace)`. Another execution may not acquire that + * authority until the owner commits `PARKED` or `RETIRED`; continuation by the exact owner and + * acquisition in a different namespace remain legal. + */ + public fun advanceExecution(record: MutationExecutionRecord) + + /** Inserts one immutable semantic attempt generation. */ + public fun insertAttempt(record: MutationAttemptRecord) + + /** Writes the four conflict-receipt fields of an existing attempt exactly once. */ + public fun recordConflictReceipt(record: MutationAttemptRecord) + + /** + * Inserts a write-once acknowledgement. Its codec version identifies its own authoritative + * bytes and may differ from the referenced attempt version; an exact duplicate is idempotent. + */ + public fun insertAck(record: MutationAckRecord) + + /** Allocates an ID and appends normalized, UTF-8-bounded failure evidence. */ + public fun appendFailure( + clientId: String, + clientSequence: Long, + generation: Int, + kind: MutationFailureKind, + detail: String, + message: String, + occurredAt: Long, + ): MutationFailureRecord + + /** Inserts one initial `PENDING` effect. */ + public fun insertEffect(record: MutationEffectRecord) + + /** Advances one effect to a terminal disposition. */ + public fun advanceEffect(record: MutationEffectRecord) + + /** Inserts a pending alias edge; an exact duplicate is idempotent. */ + public fun insertAlias(record: MutationKeyAliasRecord) + + /** Activates a pending alias edge. */ + public fun advanceAlias(record: MutationKeyAliasRecord) + + /** Inserts one pending tombstone generation. */ + public fun insertTombstone(record: MutationKeyTombstoneRecord) + + /** + * Advances one tombstone generation forward. + * + * Same-client successors ordinarily have a greater sequence. A lower positive sequence is + * accepted only as part of the exact causal retirement transaction that proves the successor's + * unique namespace authority, PRESENT acknowledgement and alias, terminal effects, routing + * activation, truthful retirement prefix, and complete tombstone collapse atomically. + */ + public fun advanceTombstone(record: MutationKeyTombstoneRecord) + + /** + * Removes eligible records at or below [serverConfirmedRetiredThroughSequence]. + * + * The supplied prefix cannot exceed the client row's persisted server-confirmed prefix. + * Alias redirects and active or pending tombstone generations are never removed by ordinary + * prune. A superseded tombstone is eligible only when its creator is at/below this confirmed + * prefix and its superseding intent is also at/below that intent's owning client's persisted + * server-confirmed prefix. + */ + public fun prune( + clientId: String, + serverConfirmedRetiredThroughSequence: Long, + ) +} diff --git a/store6-mutations/src/commonTest/kotlin/ConflictPolicyDocsSnippet.kt b/store6-mutations/src/commonTest/kotlin/ConflictPolicyDocsSnippet.kt new file mode 100644 index 000000000..75578ad3f --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/ConflictPolicyDocsSnippet.kt @@ -0,0 +1,38 @@ +// docs:snippet:mutations-conflicts-policy +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.mutations.MutationConflictResolution +import org.mobilenativefoundation.store6.mutations.MutationPresence +import org.mobilenativefoundation.store6.mutations.MutationStoreBuilder + +data class Profile( + val displayName: String, + val avatarUrl: String, +) + +fun MutationStoreBuilder.installProfileConflicts() { + conflicts { + precondition { candidate -> + candidate.capturedMeta?.takeIf { it.etag != null } + } + + merge { base, mine, theirs -> + if ( + base is MutationPresence.Present && + mine is MutationPresence.Present && + theirs is MutationPresence.Present && + theirs.value.displayName == base.value.displayName + ) { + MutationConflictResolution.Retry( + MutationPresence.Present( + theirs.value.copy(displayName = mine.value.displayName), + ), + ) + } else { + MutationConflictResolution.ServerWins + } + } + } +} +// docs:snippet:end diff --git a/store6-mutations/src/commonTest/kotlin/MutationServerHttpDocsSnippet.kt b/store6-mutations/src/commonTest/kotlin/MutationServerHttpDocsSnippet.kt new file mode 100644 index 000000000..69a0ec77e --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/MutationServerHttpDocsSnippet.kt @@ -0,0 +1,77 @@ +// docs:snippet:mutations-server-http-adapter +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreMeta +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.seam.StoreResults +import org.mobilenativefoundation.store6.mutations.MutationAbsentAck +import org.mobilenativefoundation.store6.mutations.MutationAck +import org.mobilenativefoundation.store6.mutations.MutationPresentAck +import org.mobilenativefoundation.store6.mutations.MutationPush +import org.mobilenativefoundation.store6.mutations.MutationRetirement +import org.mobilenativefoundation.store6.mutations.MutationRetirementAck +import org.mobilenativefoundation.store6.mutations.MutationServer + +private class HttpUserKey(private val id: String) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("users") + override fun canonicalId(): String = id +} + +private data class HttpUser(val id: String, val name: String) + +private sealed interface PushResponse { + data class Present( + val authoritative: HttpUser, + val etag: String?, + val canonicalId: String?, + ) : PushResponse + + data class Absent(val etag: String?) : PushResponse + + data class Conflict( + val serverMeta: StoreMeta?, + val message: String, + val cause: Throwable? = null, + ) : PushResponse +} + +private interface UserMutationHttpClient { + // Deterministically encode the documented carrier fields and route only by request.identity. + suspend fun push(request: MutationPush): PushResponse + + suspend fun retire(request: MutationRetirement): Long +} + +private class HttpUserMutationServer( + private val http: UserMutationHttpClient, +) : MutationServer { + override suspend fun push( + request: MutationPush, + ): MutationAck = + when (val response = http.push(request)) { + is PushResponse.Present -> + MutationPresentAck( + authoritative = response.authoritative, + etag = response.etag, + canonicalKey = response.canonicalId?.let(::HttpUserKey), + ) + + is PushResponse.Absent -> + MutationAbsentAck(etag = response.etag) + + is PushResponse.Conflict -> + throw StoreResults.exception( + StoreResults.conflict(response.serverMeta, response.message), + response.cause, + ) + } + + override suspend fun retire( + request: MutationRetirement, + ): MutationRetirementAck = + MutationRetirementAck( + confirmedThroughSequence = http.retire(request), + ) +} +// docs:snippet:end diff --git a/store6-mutations/src/commonTest/kotlin/MutatorRegistrationDocsSnippet.kt b/store6-mutations/src/commonTest/kotlin/MutatorRegistrationDocsSnippet.kt new file mode 100644 index 000000000..165f393fd --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/MutatorRegistrationDocsSnippet.kt @@ -0,0 +1,112 @@ +// docs:snippet:mutations-mutators-five-shapes +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.mutations.MutationCodec +import org.mobilenativefoundation.store6.mutations.MutationPresence +import org.mobilenativefoundation.store6.mutations.MutatorRef +import org.mobilenativefoundation.store6.mutations.MutatorRegistry +import org.mobilenativefoundation.store6.mutations.StaleSet +import org.mobilenativefoundation.store6.mutations.mutatorRegistry + +private class UserKey(private val id: String) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("users") + override fun canonicalId(): String = id +} + +private data class User(val id: String, val name: String) +private data class Rename(val name: String) +private data class NewUser(val id: String, val name: String) + +private sealed interface UserCommand { + data class Replace(val user: User) : UserCommand + data object Delete : UserCommand + data object Decline : UserCommand +} + +private data class UserMutators( + val registry: MutatorRegistry, + val genericRef: MutatorRef, + val updateRef: MutatorRef, + val createRef: MutatorRef, + val deleteRef: MutatorRef, + val upsertRef: MutatorRef, +) + +private fun noStales(): StaleSet = + StaleSet(keys = emptySet(), namespaces = emptySet()) + +private fun buildUserMutators( + userCommandCodec: MutationCodec, + renameCodec: MutationCodec, + newUserCodec: MutationCodec, + userCodec: MutationCodec, +): UserMutators { + lateinit var genericRef: MutatorRef + lateinit var updateRef: MutatorRef + lateinit var createRef: MutatorRef + lateinit var deleteRef: MutatorRef + lateinit var upsertRef: MutatorRef + + val registry = + mutatorRegistry { + genericRef = + mutator( + id = "user-command", + version = 1, + codec = userCommandCodec, + stales = { _, _ -> noStales() }, + project = { _, command -> + when (command) { + is UserCommand.Replace -> MutationPresence.Present(command.user) + UserCommand.Delete -> MutationPresence.Absent + UserCommand.Decline -> null + } + }, + ) + + updateRef = + update( + id = "rename-user", + version = 1, + codec = renameCodec, + stales = { _, _ -> noStales() }, + project = { user, rename -> user.copy(name = rename.name) }, + ) + + createRef = + create( + id = "create-user", + version = 1, + codec = newUserCodec, + stales = { _, _ -> noStales() }, + project = { args -> User(id = args.id, name = args.name) }, + ) + + deleteRef = + delete( + id = "delete-user", + stales = { _, _ -> noStales() }, + ) + + upsertRef = + upsert( + id = "put-user", + version = 1, + codec = userCodec, + stales = { _, _ -> noStales() }, + project = { _, user -> MutationPresence.Present(user) }, + ) + } + + return UserMutators( + registry = registry, + genericRef = genericRef, + updateRef = updateRef, + createRef = createRef, + deleteRef = deleteRef, + upsertRef = upsertRef, + ) +} +// docs:snippet:end diff --git a/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/FailPointJournalStorage.kt b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/FailPointJournalStorage.kt new file mode 100644 index 000000000..74157bc22 --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/FailPointJournalStorage.kt @@ -0,0 +1,370 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, +) + +package org.mobilenativefoundation.store6.mutations + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.mobilenativefoundation.store6.mutations.storage.MutationClientRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationEffectDisposition +import org.mobilenativefoundation.store6.mutations.storage.MutationEffectRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionPhase +import org.mobilenativefoundation.store6.mutations.storage.MutationJournalStorage +import org.mobilenativefoundation.store6.mutations.storage.MutationJournalTransaction + +/** One-shot failure raised inside a selected journal transaction. */ +internal class FailPointTransactionException : + RuntimeException("Injected semantic journal transaction failure") + +/** Simulated process death immediately before or immediately after a selected commit. */ +internal class FailPointProcessDeathException( + internal val committed: Boolean, +) : CancellationException( + if (committed) { + "Injected process death after semantic journal transaction commit" + } else { + "Injected process death before semantic journal transaction commit" + }, + ) + +internal sealed interface JournalFailPoint + +/** Engine transition points retained as the original closed four-entry enum. */ +internal enum class JournalFailPointBoundary : JournalFailPoint { + ACK_RECEIPT, + ADOPTION_ADVANCE, + EFFECT_MARKER, + FINALIZATION, + + ; + + companion object { + internal val CHECKPOINT_CONFIRMATION: JournalFailPoint = + JournalStorageOperationFailPoint.CHECKPOINT_CONFIRMATION + internal val BEFORE_PRUNE: JournalFailPoint = JournalStorageOperationFailPoint.BEFORE_PRUNE + internal val PRUNE_COMMIT: JournalFailPoint = JournalStorageOperationFailPoint.PRUNE_COMMIT + } +} + +/** + * Operation points kept outside [JournalFailPointBoundary] so its existing exhaustive callers do + * not widen. Together, `FINALIZATION` before/after, `BEFORE_PRUNE`, and `PRUNE_COMMIT` + * before/after are the exact local equivalents of C5's five frozen testing-kit modes. + */ +private enum class JournalStorageOperationFailPoint : JournalFailPoint { + CHECKPOINT_CONFIRMATION, + BEFORE_PRUNE, + PRUNE_COMMIT, +} + +private enum class JournalFailPointMode { + FAIL_TRANSACTION, + KILL_BEFORE_COMMIT, + KILL_AFTER_COMMIT, +} + +private data class ArmedJournalTransition( + val predicate: (before: MutationExecutionRecord, after: MutationExecutionRecord) -> Boolean, + val mode: JournalFailPointMode, + val remainingMatches: Int, +) + +private data class ArmedJournalFailPoint( + val boundary: JournalFailPoint, + val mode: JournalFailPointMode, + val remainingMatches: Int, +) + +/** + * Test-only journal decorator for a semantic transaction failure or process death. + * + * The predicate observes an execution row immediately before and after a requested transition. + * An unrelated or rolled-back non-terminal match leaves the fail point armed. Transaction-failure + * and kill-before modes clear immediately and roll back; kill-after clears only after the delegate + * commits and then raises the process-death analogue outside that transaction. + */ +internal class FailPointJournalStorage( + private val delegate: MutationJournalStorage, +) : MutationJournalStorage { + private val gate = Mutex() + private var armedTransition: ArmedJournalTransition? = null + private var armedBoundary: ArmedJournalFailPoint? = null + + internal val triggeredBoundaries: MutableList = mutableListOf() + + internal val hasArmedFailPoint: Boolean + get() { + check(gate.tryLock()) { "Cannot inspect a fail point while a transaction is active." } + return try { + armedTransition != null || armedBoundary != null + } finally { + gate.unlock() + } + } + + internal fun armFailTransaction( + predicate: (before: MutationExecutionRecord, after: MutationExecutionRecord) -> Boolean, + ) { + armTransition(predicate, JournalFailPointMode.FAIL_TRANSACTION, occurrence = 1) + } + + internal fun armKillBeforeCommit( + predicate: (before: MutationExecutionRecord, after: MutationExecutionRecord) -> Boolean, + occurrence: Int = 1, + ) { + armTransition(predicate, JournalFailPointMode.KILL_BEFORE_COMMIT, occurrence) + } + + internal fun armKillAfterCommit( + predicate: (before: MutationExecutionRecord, after: MutationExecutionRecord) -> Boolean, + occurrence: Int = 1, + ) { + armTransition(predicate, JournalFailPointMode.KILL_AFTER_COMMIT, occurrence) + } + + internal fun armFailTransaction( + boundary: JournalFailPoint, + occurrence: Int = 1, + ) { + armBoundary(boundary, JournalFailPointMode.FAIL_TRANSACTION, occurrence) + } + + internal fun armKillBeforeCommit( + boundary: JournalFailPoint, + occurrence: Int = 1, + ) { + armBoundary(boundary, JournalFailPointMode.KILL_BEFORE_COMMIT, occurrence) + } + + internal fun armKillAfterCommit( + boundary: JournalFailPoint, + occurrence: Int = 1, + ) { + armBoundary(boundary, JournalFailPointMode.KILL_AFTER_COMMIT, occurrence) + } + + private fun armTransition( + predicate: (before: MutationExecutionRecord, after: MutationExecutionRecord) -> Boolean, + mode: JournalFailPointMode, + occurrence: Int, + ) { + require(occurrence > 0) { "Fail-point occurrence must be positive." } + check(gate.tryLock()) { "Cannot arm a fail point while a transaction is active." } + try { + check(armedTransition == null && armedBoundary == null) { + "A journal fail point is already armed." + } + armedTransition = ArmedJournalTransition(predicate, mode, occurrence) + } finally { + gate.unlock() + } + } + + private fun armBoundary( + boundary: JournalFailPoint, + mode: JournalFailPointMode, + occurrence: Int, + ) { + require(occurrence > 0) { "Fail-point occurrence must be positive." } + require( + boundary != JournalFailPointBoundary.BEFORE_PRUNE || + mode == JournalFailPointMode.KILL_BEFORE_COMMIT + ) { "BEFORE_PRUNE supports only kill-before semantics." } + check(gate.tryLock()) { "Cannot arm a fail point while a transaction is active." } + try { + check(armedTransition == null && armedBoundary == null) { + "A journal fail point is already armed." + } + armedBoundary = ArmedJournalFailPoint(boundary, mode, occurrence) + } finally { + gate.unlock() + } + } + + override suspend fun transaction(block: (MutationJournalTransaction) -> R): R = + gate.withLock { + var transitionMatches = 0 + var boundaryMatches = 0 + var terminalTransitionMatched = false + var terminalBoundaryMatched = false + var killAfterCommit = false + + fun transitionMode( + before: MutationExecutionRecord, + after: MutationExecutionRecord, + ): JournalFailPointMode? { + val armed = armedTransition ?: return null + if (terminalTransitionMatched || !armed.predicate(before, after)) return null + transitionMatches += 1 + if (transitionMatches < armed.remainingMatches) return null + terminalTransitionMatched = true + return armed.mode + } + + fun boundaryMode(boundary: JournalFailPoint): JournalFailPointMode? { + val armed = armedBoundary ?: return null + if (terminalBoundaryMatched || armed.boundary != boundary) return null + boundaryMatches += 1 + if (boundaryMatches < armed.remainingMatches) return null + terminalBoundaryMatched = true + return armed.mode + } + + fun recordBoundary(boundary: JournalFailPoint) { + if (boundary is JournalFailPointBoundary) { + triggeredBoundaries += boundary + } + } + + fun handleTransition(mode: JournalFailPointMode?) { + when (mode) { + JournalFailPointMode.FAIL_TRANSACTION -> { + armedTransition = null + throw FailPointTransactionException() + } + JournalFailPointMode.KILL_BEFORE_COMMIT -> { + armedTransition = null + throw FailPointProcessDeathException(committed = false) + } + JournalFailPointMode.KILL_AFTER_COMMIT -> killAfterCommit = true + null -> Unit + } + } + + fun handleBoundary( + boundary: JournalFailPoint, + mode: JournalFailPointMode?, + ) { + when (mode) { + JournalFailPointMode.FAIL_TRANSACTION -> { + armedBoundary = null + recordBoundary(boundary) + throw FailPointTransactionException() + } + JournalFailPointMode.KILL_BEFORE_COMMIT -> { + armedBoundary = null + recordBoundary(boundary) + throw FailPointProcessDeathException(committed = false) + } + JournalFailPointMode.KILL_AFTER_COMMIT -> killAfterCommit = true + null -> Unit + } + } + + val result = + delegate.transaction { transaction -> + block( + object : MutationJournalTransaction by transaction { + override fun advanceExecution(record: MutationExecutionRecord) { + val before = + transaction.executions(record.clientId).single { + it.clientSequence == record.clientSequence + } + handleTransition(transitionMode(before, record)) + boundaryFor(before, record)?.let { boundary -> + handleBoundary(boundary, boundaryMode(boundary)) + } + transaction.advanceExecution(record) + } + + override fun advanceEffect(record: MutationEffectRecord) { + val before = + transaction.effects(record.clientId).single { + it.clientSequence == record.clientSequence && + it.effectIndex == record.effectIndex + } + if ( + before.disposition == MutationEffectDisposition.PENDING && + record.disposition == MutationEffectDisposition.APPLIED + ) { + val boundary = JournalFailPointBoundary.EFFECT_MARKER + handleBoundary(boundary, boundaryMode(boundary)) + } + transaction.advanceEffect(record) + } + + override fun confirmRetiredThrough( + clientId: String, + requestedThroughSequence: Long, + serverConfirmedThroughSequence: Long, + ): MutationClientRecord { + val persisted = + transaction.confirmRetiredThrough( + clientId, + requestedThroughSequence, + serverConfirmedThroughSequence, + ) + val boundary = JournalFailPointBoundary.CHECKPOINT_CONFIRMATION + handleBoundary(boundary, boundaryMode(boundary)) + return persisted + } + + override fun prune( + clientId: String, + serverConfirmedRetiredThroughSequence: Long, + ) { + val beforePrune = JournalFailPointBoundary.BEFORE_PRUNE + handleBoundary(beforePrune, boundaryMode(beforePrune)) + transaction.prune(clientId, serverConfirmedRetiredThroughSequence) + val pruneCommit = JournalFailPointBoundary.PRUNE_COMMIT + handleBoundary(pruneCommit, boundaryMode(pruneCommit)) + } + }, + ) + } + + if (transitionMatches > 0) { + val armed = checkNotNull(armedTransition) + if (terminalTransitionMatched) { + check(armed.mode == JournalFailPointMode.KILL_AFTER_COMMIT) + armedTransition = null + } else { + armedTransition = + armed.copy( + remainingMatches = armed.remainingMatches - transitionMatches, + ) + } + } + if (boundaryMatches > 0) { + val armed = checkNotNull(armedBoundary) + if (terminalBoundaryMatched) { + check(armed.mode == JournalFailPointMode.KILL_AFTER_COMMIT) + armedBoundary = null + recordBoundary(armed.boundary) + } else { + armedBoundary = + armed.copy( + remainingMatches = armed.remainingMatches - boundaryMatches, + ) + } + } + if (killAfterCommit) { + throw FailPointProcessDeathException(committed = true) + } + result + } + + private fun boundaryFor( + before: MutationExecutionRecord, + after: MutationExecutionRecord, + ): JournalFailPointBoundary? = + when { + before.phase == MutationExecutionPhase.INFLIGHT && + after.phase == MutationExecutionPhase.ACKED -> + JournalFailPointBoundary.ACK_RECEIPT + + before.phase == MutationExecutionPhase.ACKED && + after.phase == MutationExecutionPhase.EFFECTS_PENDING -> + JournalFailPointBoundary.ADOPTION_ADVANCE + + before.phase == MutationExecutionPhase.EFFECTS_PENDING && + after.phase == MutationExecutionPhase.RETIRED -> + JournalFailPointBoundary.FINALIZATION + + else -> null + } +} diff --git a/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAckOrchestrationTest.kt b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAckOrchestrationTest.kt new file mode 100644 index 000000000..465bd682a --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAckOrchestrationTest.kt @@ -0,0 +1,756 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, +) + +package org.mobilenativefoundation.store6.mutations + +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreException +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.seam.StoreWriteHandle +import org.mobilenativefoundation.store6.mutations.storage.InMemoryMutationJournalStorage +import org.mobilenativefoundation.store6.mutations.storage.MutationAckRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationAliasState +import org.mobilenativefoundation.store6.mutations.storage.MutationClientRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationEffectDisposition +import org.mobilenativefoundation.store6.mutations.storage.MutationEffectRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionPhase as StoredPhase +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationFailureRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationIntentRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationJournalStorage +import org.mobilenativefoundation.store6.mutations.storage.MutationKeyAliasRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationKeyTombstoneRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationTombstoneState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +class MutationAckOrchestrationTest { + @Test + fun postAckResolverFailure_retainsAckOrEffectPhaseAndNeverRepushes() = runTest { + val failures = + PostAckHeldPhase.entries.flatMap { heldPhase -> + assertPostAckResolverFailure(heldPhase) + } + assertTrue(failures.isEmpty(), failures.joinToString(separator = "\n")) + } + + @Test + fun unknownAckValueCodecVersion_staysAckedAndRetriesWithoutRepush() = runTest { + val backing = InMemoryMutationJournalStorage() + val storage = FailPointJournalStorage(backing) + val mutations = AckMutations() + val backend = retainingAckBackend() + val key = MutationsTestKey("unknown-ack-codec") + val initialCodec = SelectiveStringCodec(mutableSetOf(99)) + storage.armFailTransaction(JournalFailPointBoundary.ADOPTION_ADVANCE) + val initial = + openAckEngine( + storage = storage, + mutations = mutations, + backend = backend, + handle = RecordingAckWriteHandle(), + valueCodecVersion = 99, + valueCodec = initialCodec, + ) + val mutationId = initial.mutate(key, mutations.set, "authoritative") + + assertNotNull(captureFailure { initial.drain(key) }) + val held = storage.ackState(mutationId) + assertEquals(StoredPhase.ACKED, held.execution.phase) + assertEquals(99, held.acks.single().valueCodecVersion) + val pushCount = backend.receivedPushes.size + + val reopenedCodec = SelectiveStringCodec(mutableSetOf(1)) + val reopened = + openAckEngine( + storage = storage, + mutations = mutations, + backend = backend, + handle = RecordingAckWriteHandle(), + valueCodecVersion = 1, + valueCodec = reopenedCodec, + ) + reopened.drain(key) + + val blocked = storage.ackState(mutationId) + assertEquals(StoredPhase.ACKED, blocked.execution.phase) + assertTrue(blocked.failures.any { it.kind == MutationFailureKind.CODEC }) + assertEquals(pushCount, backend.receivedPushes.size) + assertTrue(reopened.deadLetters().isEmpty()) + + reopenedCodec.supportedVersions += 99 + reopened.drain(key) + + assertEquals(StoredPhase.RETIRED, storage.ackState(mutationId).execution.phase) + assertEquals(pushCount, backend.receivedPushes.size) + assertTrue(reopened.deadLetters().isEmpty()) + } + + @Test + fun ackValidationPrecedesAdoptionEffectsAndRetirement() = runTest { + val backing = InMemoryMutationJournalStorage() + val storage = FailPointJournalStorage(backing) + val effectTarget = MutationsTestKey("invalid-ack-effect") + val mutations = AckMutations(staleKey = effectTarget) + val backend = FakeBackend() + val source = MutationsTestKey("invalid-ack-source") + val firstTarget = MutationsTestKey("invalid-ack-target-1") + val secondTarget = MutationsTestKey("invalid-ack-target-2") + backend.pushBehavior = { _, value -> + MutationPresentAck( + authoritative = value, + etag = "etag-${backend.receivedPushes.size}", + canonicalKey = + if (backend.receivedPushes.size == 1) firstTarget else secondTarget, + ) + } + val handle = RecordingAckWriteHandle() + val engine = openAckEngine(storage, mutations, backend, handle) + val mutationId = engine.mutate(source, mutations.set, "invalid-ack") + val revisionBefore = engine.aliasRevision(source.identity()).value + storage.armFailTransaction(JournalFailPointBoundary.ACK_RECEIPT) + + assertNotNull(captureFailure { engine.drain(source) }) + val replayable = storage.ackState(mutationId) + assertEquals(StoredPhase.INFLIGHT, replayable.execution.phase) + assertTrue(replayable.acks.isEmpty()) + assertEquals(1, backend.receivedPushes.size) + + engine.drain(source) + + val parked = storage.ackState(mutationId) + val protocolFailures = parked.failures.filter { it.kind == MutationFailureKind.PROTOCOL } + assertEquals(StoredPhase.PARKED, parked.execution.phase) + assertEquals(1, parked.execution.attempt) + assertNotNull(parked.execution.lastAttemptAt) + assertEquals(protocolFailures.single().failureId, parked.execution.activeFailureId) + assertNull(parked.execution.retiredAt) + assertTrue(parked.acks.isEmpty()) + assertTrue(parked.aliases.isEmpty()) + assertTrue(parked.tombstones.isEmpty()) + assertEquals(1, parked.effects.size) + assertEquals(MutationEffectDisposition.PENDING, parked.effects.single().disposition) + assertTrue(handle.applied.isEmpty()) + assertTrue(handle.confirmed.isEmpty()) + assertTrue(handle.markedStale.isEmpty()) + assertEquals(0L, assertNotNull(parked.client).retiredThroughSequence) + assertEquals(revisionBefore, engine.aliasRevision(source.identity()).value) + val deadLetter = engine.deadLetters().single() + assertEquals(mutationId, deadLetter.mutationId) + assertEquals(MutationFailureKind.PROTOCOL, deadLetter.failure.kind) + + val pushCount = backend.receivedPushes.size + engine.drain(source) + assertEquals(pushCount, backend.receivedPushes.size) + } + + @Test + fun adoptionFailure_staysAckedAndRetriesWithoutRepush() = runTest { + val errors = mutableListOf() + val storage = InMemoryMutationJournalStorage() + val mutations = AckMutations() + val backend = retainingAckBackend() + val handle = RecordingAckWriteHandle(remainingApplyFailures = 1) + val engine = openAckEngine(storage, mutations, backend, handle) + val key = MutationsTestKey("adoption-retry") + val mutationId = engine.mutate(key, mutations.set, "accepted") + + val exposed = captureFailure { engine.drain(key) } + expect(errors, "keyed adoption failure is sanctioned") { + val adoptionFailure = assertIs(assertNotNull(exposed)) + assertEquals("injected adoption failure", adoptionFailure.message) + } + val held = storage.ackState(mutationId) + expect(errors, "adoption failure retains ACKED") { + assertEquals(StoredPhase.ACKED, held.execution.phase) + } + expect(errors, "adoption failure records evidence") { + assertTrue(held.failures.any { it.kind == MutationFailureKind.ADOPTION }) + } + val heldDeadLetters = engine.deadLetters() + expect(errors, "adoption failure never retransmits or parks") { + assertEquals(1, backend.receivedPushes.size) + assertTrue(heldDeadLetters.isEmpty()) + } + + val cleanFailure = captureFailure { engine.drain(key) } + expect(errors, "later adoption pass completes") { assertNull(cleanFailure) } + val completed = storage.ackState(mutationId) + val completedDeadLetters = engine.deadLetters() + expect(errors, "later adoption retires") { + assertEquals(StoredPhase.RETIRED, completed.execution.phase) + assertEquals(2, handle.applyAttempts) + assertEquals(1, handle.applied.size) + assertEquals(1, handle.confirmed.size) + assertEquals(1, backend.receivedPushes.size) + assertTrue(completedDeadLetters.isEmpty()) + } + assertTrue(errors.isEmpty(), errors.joinToString(separator = "\n")) + } + + @Test + fun persistenceFailureAtEachPostAckBoundary_replaysFromLastDurablePhase() = runTest { + val errors = + listOf( + JournalFailPointBoundary.ACK_RECEIPT, + JournalFailPointBoundary.ADOPTION_ADVANCE, + JournalFailPointBoundary.EFFECT_MARKER, + JournalFailPointBoundary.FINALIZATION, + ).flatMap { boundary -> + persistenceReceiptForms(boundary).flatMap { receipt -> + assertPersistenceBoundary(boundary, receipt) + } + } + assertTrue(errors.isEmpty(), errors.joinToString(separator = "\n")) + } + + @Test + fun ackAliasActivationRebasesQueuedSourceAndTargetSiblings() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = AckMutations() + val backend = retainingAckBackend() + val source = MutationsTestKey("queue-source") + val target = MutationsTestKey("queue-target") + backend.pushBehavior = { key, value -> + MutationPresentAck( + authoritative = value, + etag = "etag-${backend.receivedPushes.size}", + canonicalKey = target.takeIf { key.canonicalId() == source.canonicalId() }, + ) + } + val store = + mutationStore( + registry = mutations.registry, + server = backend, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + ) { + fetcher { backend.load(it) } + journalStorage(storage) + } + try { + val head = store.mutate(source, mutations.set, "head") + val sourceSibling = store.mutate(source, mutations.append, "+source") + val targetSibling = store.mutate(target, mutations.append, "+target") + + store.drain(source) + + assertEquals( + listOf("queue-source", "queue-target", "queue-target"), + backend.receivedPushes.map { it.identity.canonicalId }, + ) + assertEquals(listOf(1L, 2L, 3L), backend.receivedPushes.map { it.clientSequence }) + assertEquals(listOf("head", "head+source", "head+source+target"), backend.pushedValues) + assertEquals(emptyList(), store.pending(source)) + assertEquals(emptyList(), store.pending(target)) + assertEquals(emptyList(), store.pendingWrites()) + for (mutationId in listOf(head, sourceSibling, targetSibling)) { + assertEquals(StoredPhase.RETIRED, storage.ackState(mutationId).execution.phase) + } + val terminal = storage.ackState(head) + assertEquals(MutationAliasState.ACTIVE, terminal.aliases.single().state) + assertEquals(3L, assertNotNull(terminal.client).retiredThroughSequence) + } finally { + store.close() + } + } +} + +private enum class PostAckHeldPhase( + val failBoundary: JournalFailPointBoundary, + val storedPhase: StoredPhase, +) { + ACKED(JournalFailPointBoundary.ADOPTION_ADVANCE, StoredPhase.ACKED), + EFFECTS_PENDING(JournalFailPointBoundary.FINALIZATION, StoredPhase.EFFECTS_PENDING), +} + +private enum class PersistenceReceipt { + PRESENT, + PRESENT_ALIAS, + ABSENT_TOMBSTONE, +} + +private fun persistenceReceiptForms( + boundary: JournalFailPointBoundary, +): List = + if ( + boundary == JournalFailPointBoundary.ACK_RECEIPT || + boundary == JournalFailPointBoundary.FINALIZATION + ) { + listOf(PersistenceReceipt.PRESENT_ALIAS, PersistenceReceipt.ABSENT_TOMBSTONE) + } else { + listOf(PersistenceReceipt.PRESENT) + } + +private suspend fun assertPostAckResolverFailure(heldPhase: PostAckHeldPhase): List { + val errors = mutableListOf() + val storage = FailPointJournalStorage(InMemoryMutationJournalStorage()) + val mutations = AckMutations() + val backend = retainingAckBackend() + val source = MutationsTestKey("resolver-${heldPhase.name.lowercase()}-source") + val target = MutationsTestKey("resolver-${heldPhase.name.lowercase()}-target") + backend.pushBehavior = { _, value -> + MutationPresentAck(value, "resolver-etag", target) + } + val resolver = ScriptedAckResolver() + val engine = + openAckEngine( + storage = storage, + mutations = mutations, + backend = backend, + handle = RecordingAckWriteHandle(), + keyResolver = resolver, + ) + val mutationId = engine.mutate(source, mutations.set, "resolver-value") + storage.armFailTransaction(heldPhase.failBoundary) + + val stagingFailure = captureFailure { engine.drain(source) } + val staged = storage.ackState(mutationId) + expect(errors, "staging leaves ${heldPhase.storedPhase}") { + assertNotNull(stagingFailure) + assertEquals(heldPhase.storedPhase, staged.execution.phase) + } + val pushCount = backend.receivedPushes.size + engine.clearLiveKeyCache() + resolver.failure = IllegalStateException("post-ack resolver unavailable") + + val exposed = captureFailure { engine.drain(source) } + expect(errors, "resolver failure is sanctioned") { + val storeFailure = assertIs(assertNotNull(exposed)) + assertIs(storeFailure.error) + } + val failed = storage.ackState(mutationId) + val failedDeadLetters = engine.deadLetters() + expect(errors, "resolver failure retains phase and evidence") { + assertEquals(heldPhase.storedPhase, failed.execution.phase) + assertTrue(failed.failures.any { it.kind == MutationFailureKind.IDENTITY }) + assertEquals(pushCount, backend.receivedPushes.size) + assertTrue(failedDeadLetters.isEmpty()) + } + + resolver.failure = null + val cleanFailure = captureFailure { engine.drain(source) } + val completed = storage.ackState(mutationId) + val completedDeadLetters = engine.deadLetters() + expect(errors, "later resolver success completes without a push") { + assertNull(cleanFailure) + assertEquals(StoredPhase.RETIRED, completed.execution.phase) + assertEquals(pushCount, backend.receivedPushes.size) + assertTrue(completedDeadLetters.isEmpty()) + } + return errors.map { "${heldPhase.name}: $it" } +} + +private suspend fun assertPersistenceBoundary( + boundary: JournalFailPointBoundary, + receipt: PersistenceReceipt, +): List { + val errors = mutableListOf() + val storage = FailPointJournalStorage(InMemoryMutationJournalStorage()) + val source = MutationsTestKey("${boundary.name.lowercase()}-source") + val target = MutationsTestKey("${boundary.name.lowercase()}-target") + val effectTarget = MutationsTestKey("${boundary.name.lowercase()}-effect") + val mutations = + AckMutations( + staleKey = effectTarget.takeIf { boundary == JournalFailPointBoundary.EFFECT_MARKER }, + ) + val backend = + retainingAckBackend().apply { + dedupingPushBehavior = true + pushBehavior = { _, value -> + MutationPresentAck( + authoritative = value, + etag = "boundary-etag", + canonicalKey = target.takeIf { receipt == PersistenceReceipt.PRESENT_ALIAS }, + ) + } + absentPushBehavior = { _ -> MutationAbsentAck(etag = "boundary-absent-etag") } + } + val firstHandle = RecordingAckWriteHandle() + val first = openAckEngine(storage, mutations, backend, firstHandle) + val mutationId = + when (receipt) { + PersistenceReceipt.PRESENT, + PersistenceReceipt.PRESENT_ALIAS, + -> first.mutate(source, mutations.set, "boundary-value") + + PersistenceReceipt.ABSENT_TOMBSTONE -> first.mutate(source, mutations.deleteRef, Unit) + } + val revisionBefore = first.aliasRevision(source.identity()).value + val changesBefore = first.changes.replayCache.map { it.fingerprint() } + storage.armFailTransaction(boundary) + + val exposed = captureFailure { first.drain(source) } + expect(errors, "$boundary/$receipt failure is surfaced") { + assertIs(assertNotNull(exposed)) + } + val held = storage.ackState(mutationId) + val expectedHeldPhase = + when (boundary) { + JournalFailPointBoundary.ACK_RECEIPT -> StoredPhase.INFLIGHT + JournalFailPointBoundary.ADOPTION_ADVANCE -> StoredPhase.ACKED + JournalFailPointBoundary.EFFECT_MARKER, + JournalFailPointBoundary.FINALIZATION, + -> StoredPhase.EFFECTS_PENDING + } + expect(errors, "$boundary/$receipt retains the last durable phase") { + assertEquals(expectedHeldPhase, held.execution.phase) + assertEquals(0L, assertNotNull(held.client).retiredThroughSequence) + assertEquals(listOf(boundary), storage.triggeredBoundaries) + assertTrue(!storage.hasArmedFailPoint) + } + expect(errors, "$boundary/$receipt publishes no premature accepted-state revision") { + assertEquals(revisionBefore, first.aliasRevision(source.identity()).value) + assertEquals(changesBefore, first.changes.replayCache.map { it.fingerprint() }) + } + when (boundary) { + JournalFailPointBoundary.ACK_RECEIPT -> + expect(errors, "$boundary/$receipt rolls back the complete receipt") { + assertTrue(held.acks.isEmpty()) + assertTrue(held.aliases.isEmpty()) + assertTrue(held.tombstones.isEmpty()) + assertEquals(0, firstHandle.applyAttempts) + assertTrue(firstHandle.cleared.isEmpty()) + assertTrue(firstHandle.confirmed.isEmpty()) + assertTrue(firstHandle.markedStale.isEmpty()) + } + + JournalFailPointBoundary.ADOPTION_ADVANCE -> + expect(errors, "$boundary leaves ACKED after external adoption") { + assertEquals(1, held.acks.size) + assertEquals(1, firstHandle.applyAttempts) + assertEquals(1, firstHandle.confirmed.size) + } + + JournalFailPointBoundary.EFFECT_MARKER -> + expect(errors, "$boundary leaves the effect pending after target success") { + assertEquals( + MutationEffectDisposition.PENDING, + assertNotNull(held.effects.singleOrNull()).disposition, + ) + assertEquals(listOf(effectTarget.identity()), firstHandle.markedStale) + } + + JournalFailPointBoundary.FINALIZATION -> + expect(errors, "$boundary/$receipt retains pending routing") { + when (receipt) { + PersistenceReceipt.PRESENT_ALIAS -> { + assertEquals( + MutationAliasState.PENDING, + assertNotNull(held.aliases.singleOrNull()).state, + ) + assertTrue(held.tombstones.isEmpty()) + } + + PersistenceReceipt.ABSENT_TOMBSTONE -> { + assertTrue(held.aliases.isEmpty()) + assertEquals( + MutationTombstoneState.PENDING, + assertNotNull(held.tombstones.singleOrNull()).state, + ) + } + + PersistenceReceipt.PRESENT -> + throw AssertionError("FINALIZATION requires an optional receipt subarm.") + } + assertEquals(source.identity(), first.terminalIdentityOf(source.identity())) + } + } + + val reopenedHandle = RecordingAckWriteHandle() + val reopened = openAckEngine(storage, mutations, backend, reopenedHandle) + val cleanFailure = captureFailure { reopened.drain(source) } + expect(errors, "$boundary/$receipt clean restart completes") { assertNull(cleanFailure) } + val completed = storage.ackState(mutationId) + val completedDeadLetters = reopened.deadLetters() + expect(errors, "$boundary/$receipt clean restart retires") { + assertEquals(StoredPhase.RETIRED, completed.execution.phase) + assertEquals(1L, assertNotNull(completed.client).retiredThroughSequence) + assertTrue(completedDeadLetters.isEmpty()) + } + when (boundary) { + JournalFailPointBoundary.ACK_RECEIPT -> + expect(errors, "$boundary/$receipt replays one deduped request and activates receipt") { + assertEquals(2, backend.receivedIdempotencyKeys.size) + assertEquals( + backend.receivedIdempotencyKeys.first(), + backend.receivedIdempotencyKeys.last(), + ) + assertEquals(1, backend.effectivePushApplications.size) + when (receipt) { + PersistenceReceipt.PRESENT_ALIAS -> { + assertEquals( + MutationAliasState.ACTIVE, + assertNotNull(completed.aliases.singleOrNull()).state, + ) + assertTrue(completed.tombstones.isEmpty()) + assertEquals(target.identity(), reopened.terminalIdentityOf(source.identity())) + assertEquals(1, reopenedHandle.applyAttempts) + assertTrue(reopenedHandle.cleared.isEmpty()) + } + + PersistenceReceipt.ABSENT_TOMBSTONE -> { + assertTrue(completed.aliases.isEmpty()) + assertEquals( + MutationTombstoneState.ACTIVE, + assertNotNull(completed.tombstones.singleOrNull()).state, + ) + assertEquals(source.identity(), reopened.terminalIdentityOf(source.identity())) + assertEquals(0, reopenedHandle.applyAttempts) + assertEquals(listOf(source.identity()), reopenedHandle.cleared) + } + + PersistenceReceipt.PRESENT -> + throw AssertionError("ACK_RECEIPT requires an optional receipt subarm.") + } + } + + JournalFailPointBoundary.ADOPTION_ADVANCE -> + expect(errors, "$boundary re-adopts without a push") { + assertEquals(2, firstHandle.applyAttempts + reopenedHandle.applyAttempts) + assertEquals(1, backend.receivedPushes.size) + } + + JournalFailPointBoundary.EFFECT_MARKER -> + expect(errors, "$boundary repeats the target before applying its marker") { + assertTrue(firstHandle.markedStale.size + reopenedHandle.markedStale.size >= 2) + assertEquals( + MutationEffectDisposition.APPLIED, + assertNotNull(completed.effects.singleOrNull()).disposition, + ) + assertEquals(1, backend.receivedPushes.size) + } + + JournalFailPointBoundary.FINALIZATION -> + expect(errors, "$boundary/$receipt activates routing only on the clean pass") { + when (receipt) { + PersistenceReceipt.PRESENT_ALIAS -> { + assertEquals( + MutationAliasState.ACTIVE, + assertNotNull(completed.aliases.singleOrNull()).state, + ) + assertTrue(completed.tombstones.isEmpty()) + assertEquals(target.identity(), reopened.terminalIdentityOf(source.identity())) + } + + PersistenceReceipt.ABSENT_TOMBSTONE -> { + assertTrue(completed.aliases.isEmpty()) + assertEquals( + MutationTombstoneState.ACTIVE, + assertNotNull(completed.tombstones.singleOrNull()).state, + ) + assertEquals(source.identity(), reopened.terminalIdentityOf(source.identity())) + } + + PersistenceReceipt.PRESENT -> + throw AssertionError("FINALIZATION requires an optional receipt subarm.") + } + assertEquals(1, backend.receivedPushes.size) + } + } + return errors.map { "$boundary/$receipt: $it" } +} + +private class AckMutations( + staleKey: MutationsTestKey? = null, +) { + lateinit var set: MutatorRef + lateinit var append: MutatorRef + lateinit var deleteRef: MutatorRef + + private val setStales: (MutationsTestKey, String) -> StaleSet = + if (staleKey == null) noStales() else typedStales(keys = setOf(staleKey)) + + val registry: MutatorRegistry = + mutatorRegistry { + set = + mutator( + id = "ack-set", + version = 1, + codec = FixtureStringArgsCodec, + stales = setStales, + ) { _, value -> MutationPresence.Present(value) } + append = + mutator( + id = "ack-append", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { base, suffix -> + MutationPresence.Present( + (base as? MutationPresence.Present)?.value.orEmpty() + suffix, + ) + } + deleteRef = delete(id = "ack-delete", stales = noStales()) + } +} + +private class ScriptedAckResolver : MutationKeyResolver { + var failure: Throwable? = null + + override suspend fun resolve(identity: MutationKeyIdentity): MutationsTestKey? { + failure?.let { throw it } + return MutationsTestKeyResolver.resolve(identity) + } +} + +private class SelectiveStringCodec( + val supportedVersions: MutableSet, +) : MutationCodec { + override fun encode(value: String): ByteArray = value.encodeToByteArray() + + override fun decode( + version: Int, + bytes: ByteArray, + ): String { + require(version in supportedVersions) { "Unsupported fixture value codec version $version." } + return bytes.decodeToString() + } +} + +private class RecordingAckWriteHandle( + var remainingApplyFailures: Int = 0, +) : StoreWriteHandle { + var applyAttempts: Int = 0 + private set + val applied = mutableListOf>() + val confirmed = mutableListOf>() + val markedStale = mutableListOf() + val cleared = mutableListOf() + + override suspend fun apply( + key: MutationsTestKey, + value: String, + ) { + applyAttempts += 1 + if (remainingApplyFailures > 0) { + remainingApplyFailures -= 1 + throw IllegalStateException("injected adoption failure") + } + applied += key.identity() to value + } + + override suspend fun markStale(key: MutationsTestKey) { + markedStale += key.identity() + } + + override suspend fun confirmFresh( + key: MutationsTestKey, + etag: String?, + ) { + confirmed += key.identity() to etag + } + + fun recordClear(key: MutationsTestKey) { + cleared += key.identity() + } +} + +private fun retainingAckBackend(): FakeBackend = + FakeBackend().apply { + retireBehavior = { + MutationRetirementAck(confirmedThroughSequence = 0L) + } + } + +private fun openAckEngine( + storage: MutationJournalStorage, + mutations: AckMutations, + backend: FakeBackend, + handle: RecordingAckWriteHandle, + keyResolver: MutationKeyResolver = MutationsTestKeyResolver, + valueCodecVersion: Int = 1, + valueCodec: MutationCodec = FixtureStringArgsCodec, +): MutationEngine { + val journal = + StorageBackedMutationJournal( + storage = storage, + registrations = mutations.registry.registrations, + clientId = ACK_CLIENT_ID, + hydrateOnFirstUse = true, + ) + return MutationEngine( + registry = mutations.registry, + server = backend, + journal = journal, + keyResolver = keyResolver, + valueCodecVersion = valueCodecVersion, + valueCodec = valueCodec, + baseReader = { "base" }, + absentAdoption = { key -> handle.recordClear(key) }, + clientId = ACK_CLIENT_ID, + ).also { it.bind(handle) } +} + +private data class AckDurableState( + val client: MutationClientRecord?, + val intent: MutationIntentRecord, + val execution: MutationExecutionRecord, + val acks: List, + val failures: List, + val effects: List, + val aliases: List, + val tombstones: List, +) + +private suspend fun MutationJournalStorage.ackState(mutationId: String): AckDurableState = + transaction { transaction -> + val intent = transaction.intents(ACK_CLIENT_ID).single { it.mutationId == mutationId } + val sequence = intent.clientSequence + AckDurableState( + client = transaction.client(ACK_CLIENT_ID), + intent = intent, + execution = transaction.executions(ACK_CLIENT_ID).single { it.clientSequence == sequence }, + acks = transaction.acks(ACK_CLIENT_ID).filter { it.clientSequence == sequence }, + failures = transaction.failures(ACK_CLIENT_ID).filter { it.clientSequence == sequence }, + effects = transaction.effects(ACK_CLIENT_ID).filter { it.clientSequence == sequence }, + aliases = + transaction.aliases().filter { + it.createdByClientId == ACK_CLIENT_ID && it.createdBySequence == sequence + }, + tombstones = + transaction.tombstones().filter { + it.createdByClientId == ACK_CLIENT_ID && it.createdBySequence == sequence + }, + ) + } + +private suspend fun captureFailure(block: suspend () -> Unit): Throwable? = + try { + block() + null + } catch (failure: Throwable) { + failure + } + +private fun expect( + errors: MutableList, + label: String, + assertion: () -> Unit, +) { + try { + assertion() + } catch (failure: AssertionError) { + errors += "$label: ${failure.message}" + } +} + +private fun StoreKey.fingerprint(): String = + "${namespace.value}:${canonicalId()}" + +private const val ACK_CLIENT_ID = "client-0" +private val TEST_TIMEOUT = 25.seconds + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { testBody() } diff --git a/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAckPathTest.kt b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAckPathTest.kt new file mode 100644 index 000000000..9d9a2ed50 --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAckPathTest.kt @@ -0,0 +1,1314 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, +) + +package org.mobilenativefoundation.store6.mutations + +import app.cash.turbine.ReceiveTurbine +import app.cash.turbine.test +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreException +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreMeta +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.store +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.core.seam.KeyStatus +import org.mobilenativefoundation.store6.core.seam.StoreWriteHandle +import org.mobilenativefoundation.store6.core.seam.runtime +import org.mobilenativefoundation.store6.mutations.storage.InMemoryMutationJournalStorage +import org.mobilenativefoundation.store6.testing.FakeBookkeeper +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlin.test.fail +import kotlin.time.Duration.Companion.seconds + +class MutationAckPathTest { + @Test + fun ack_landsSotOrigin_withZeroAdditionalFetches() = runTest { + val mutation = RenameMutation() + val backend = FakeBackend() + val key = MutationsTestKey("lands-sot") + val users = + mutationStore( + registry = mutation.registry, + server = backend, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + ) { + fetcher { backend.load(it) } + } + + try { + users.stream(key).test { + awaitValue("base") + users.mutate(key, mutation.ref, "optimistic") + assertEquals(Origin.OVERLAY, awaitValue("optimistic").origin) + val fetchesBeforeAck = backend.fetchCount + + users.drain(key) + + val landed = awaitConfirmedValue("optimistic") + assertEquals(Origin.SOT, landed.origin) + assertEquals(fetchesBeforeAck, backend.fetchCount) + cancelAndIgnoreRemainingEvents() + } + } finally { + users.close() + } + } + + @Test + fun ack_confirmFreshClearsDurableStaleness() = runTest { + val mutation = RenameMutation() + val backend = FakeBackend() + val bookkeeper = FakeBookkeeper() + val key = MutationsTestKey("clears-stale") + val users = + mutationStore( + registry = mutation.registry, + server = backend, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + ) { + fetcher { backend.load(it) } + bookkeeper(bookkeeper) + } + + try { + assertEquals("base", users.get(key)) + val fetchesBeforeAck = backend.fetchCount + users.invalidate(key) + assertTrue(assertNotNull(bookkeeper.status(key)).durablyStale) + users.mutate(key, mutation.ref, "confirmed") + + users.drain(key) + + assertFalse(assertNotNull(bookkeeper.status(key)).durablyStale) + users.stream(key).test { + val confirmed = awaitConfirmedValue("confirmed") + assertFalse(confirmed.isStale) + assertEquals(fetchesBeforeAck, backend.fetchCount) + cancelAndIgnoreRemainingEvents() + } + } finally { + users.close() + } + } + + @Test + fun ack_retiresIntent_andReprojectionShowsConfirmedBase() = runTest { + val mutation = RenameMutation() + val backend = + FakeBackend().apply { + pushBehavior = { _, value -> + MutationPresentAck( + authoritative = "confirmed:$value", + etag = "server-etag", + canonicalKey = null, + ) + } + } + val key = MutationsTestKey("retire") + val users = + mutationStore( + registry = mutation.registry, + server = backend, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + ) { + fetcher { backend.load(it) } + } + + try { + assertEquals("base", users.get(key)) + users.mutate(key, mutation.ref, "pending") + + users.drain(key) + + assertEquals(emptyList(), users.pending(key)) + users.stream(key, Freshness.LocalOnly).test { + val confirmed = awaitConfirmedValue("confirmed:pending") + assertTrue(confirmed.origin == Origin.SOT || confirmed.origin == Origin.MEMORY) + cancelAndIgnoreRemainingEvents() + } + } finally { + users.close() + } + } + + @Test + fun ack_neverReemitsOldBase() = runTest { + val mutation = RenameMutation() + val backend = + FakeBackend().apply { + pushBehavior = { _, value -> + MutationPresentAck( + authoritative = "confirmed:$value", + etag = "server-etag", + canonicalKey = null, + ) + } + } + val key = MutationsTestKey("never-old") + val users = + mutationStore( + registry = mutation.registry, + server = backend, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + ) { + fetcher { backend.load(it) } + } + + try { + users.stream(key).test { + awaitValue("base") + users.mutate(key, mutation.ref, "pending") + awaitValue("pending") + + users.drain(key) + + while (true) { + val data = awaitData() + assertNotEquals("base", data.value) + if (data.value == "confirmed:pending" && data.origin != Origin.OVERLAY) break + } + cancelAndIgnoreRemainingEvents() + } + } finally { + users.close() + } + } + + @Test + fun retireHappensAfterAdoption() = runTest { + val events = mutableListOf() + val journal = RetireOrderingJournal(events) + val mutation = RenameMutation() + val engine = + MutationEngine( + registry = mutation.registry, + server = echoingMutationServer(), + journal = journal, + baseReader = { "base" }, + ) + val key = MutationsTestKey("ordering") + engine.bind(RecordingWriteHandle(events)) + engine.mutate(key, mutation.ref, "pending") + + engine.drain(key) + + assertEquals(listOf("apply", "confirmFresh", "retire"), events) + } + + // The sealed Present variant adopts through apply -> confirmFresh, then retires; the + // accepted-state key-change signal follows the completed retirement. + @Test + fun presentAck_appliesConfirmsFreshThenRetires() = runTest { + val events = mutableListOf() + val journal = RetireOrderingJournal(events) + val mutation = RenameMutation() + val backend = + FakeBackend().apply { + pushBehavior = { _, value -> + MutationPresentAck( + authoritative = "authoritative:$value", + etag = "ack-etag", + canonicalKey = null, + ) + } + } + val applied = mutableListOf>() + val engine = + MutationEngine( + registry = mutation.registry, + server = backend, + journal = journal, + keyResolver = MutationsTestKeyResolver, + baseReader = { "base" }, + absentAdoption = { fail("a Present acknowledgement must never use the clear door") }, + ) + val handle = + object : StoreWriteHandle { + override suspend fun apply( + key: MutationsTestKey, + value: String, + ) { + events += "apply" + applied += value to null + } + + override suspend fun markStale(key: MutationsTestKey) = Unit + + override suspend fun confirmFresh( + key: MutationsTestKey, + etag: String?, + ) { + events += "confirmFresh" + applied += "confirmFresh" to etag + } + } + engine.bind(handle) + val key = MutationsTestKey("present-adoption") + engine.mutate(key, mutation.ref, "pending") + + engine.drain(key) + + assertEquals(listOf("apply", "confirmFresh", "retire"), events) + assertEquals("authoritative:pending", applied.first().first) + assertEquals("ack-etag", applied.last().second) + assertEquals(emptyList(), engine.pending(key)) + // The accepted-state handoff published this key's change after retirement. + assertSame(key, engine.changes.replayCache.single()) + } + + // The sealed Absent variant adopts through the bound clear door, then retires; the + // variant's type has no canonical key, so rekey-on-deletion is unrepresentable. + @Test + fun absentAck_clearsThenRetires_andHasNoCanonicalKey() = runTest { + val events = mutableListOf() + val journal = ClearOrderingJournal(events) + lateinit var deleteRef: MutatorRef + val registry = + mutatorRegistry { + deleteRef = delete(id = "delete", stales = noStales()) + } + val backend = FakeBackend() + val engine = + MutationEngine( + registry = registry, + server = backend, + journal = journal, + keyResolver = MutationsTestKeyResolver, + baseReader = { "resident" }, + absentAdoption = { events += "clear" }, + ) + engine.bind(FailingAdoptionHandle) + val key = MutationsTestKey("absent-adoption") + engine.mutate(key, deleteRef, Unit) + + engine.drain(key) + + assertEquals(listOf("clear", "retire"), events) + assertEquals(emptyList(), engine.pending(key)) + assertSame(key, engine.changes.replayCache.single()) + val absentPush = backend.receivedPushes.single() + assertSame(MutationPresence.Absent, absentPush.mine) + // Compile-level (with MutationProtocolTest's exhaustive proof): the Absent variant + // constructs from an etag alone — no canonicalKey exists to reference. + when (val ack = backend.lastAck) { + is MutationPresentAck -> fail("delete must be acknowledged with the Absent variant") + is MutationAbsentAck -> assertNotNull(ack.etag) + null -> fail("the push was never acknowledged") + } + } + + @Test + fun presentAndAbsentAckVariants_followDistinctAdoptionPaths() = runTest { + lateinit var rename: MutatorRef + lateinit var deleteRef: MutatorRef + val registry = + mutatorRegistry { + rename = + mutator( + id = "rename", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { _, value -> MutationPresence.Present(value) } + deleteRef = delete(id = "delete", stales = noStales()) + } + val adoptionLog = mutableListOf() + val engine = + MutationEngine( + registry = registry, + server = echoingMutationServer(), + keyResolver = MutationsTestKeyResolver, + baseReader = { "base" }, + absentAdoption = { key -> adoptionLog += "clear:${key.canonicalId()}" }, + ) + val handle = + object : StoreWriteHandle { + override suspend fun apply( + key: MutationsTestKey, + value: String, + ) { + adoptionLog += "apply:${key.canonicalId()}" + } + + override suspend fun markStale(key: MutationsTestKey) = Unit + + override suspend fun confirmFresh( + key: MutationsTestKey, + etag: String?, + ) { + adoptionLog += "confirmFresh:${key.canonicalId()}" + } + } + engine.bind(handle) + val presentKey = MutationsTestKey("adopts-present") + val absentKey = MutationsTestKey("adopts-absent") + engine.mutate(presentKey, rename, "renamed") + engine.mutate(absentKey, deleteRef, Unit) + + engine.drain(presentKey) + engine.drain(absentKey) + + assertEquals( + listOf("apply:adopts-present", "confirmFresh:adopts-present", "clear:adopts-absent"), + adoptionLog, + ) + assertEquals(emptyList(), engine.pending(presentKey)) + assertEquals(emptyList(), engine.pending(absentKey)) + } + + // A fetch that was in flight before the Absent acknowledgement may legally return its + // pre-ack snapshot; every fetch BEGUN AFTER the acknowledgement returns + // FetcherResult.Deleted — the backend coherence obligation MutationAbsentAck certifies. + @Test + fun absentAckWithPreAckInflightFetch_thenPostAckRefetchReturnsDeleted() = runTest { + lateinit var deleteRef: MutatorRef + val registry = + mutatorRegistry { + deleteRef = delete(id = "delete", stales = noStales()) + } + val backend = FakeBackend() + val key = MutationsTestKey("deleted-entity") + val users = + mutationStore( + registry = registry, + server = backend, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + ) { + fetcherOfResult { backend.loadResult(it) } + } + + val fetchEntered = CompletableDeferred() + val releaseFetch = CompletableDeferred() + try { + assertEquals("base", users.get(key)) + + // Pre-ack in-flight fetch: it enters the backend before the delete is even + // enqueued and completes while the delete is still pending — every moment of its + // flight precedes the acknowledgement, which the still-empty push log proves. + backend.loadGate = { + fetchEntered.complete(Unit) + releaseFetch.await() + } + users.invalidate(key) + val inflight = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { users.get(key, Freshness.MustBeFresh) } + } + fetchEntered.await() + users.mutate(key, deleteRef, Unit) + backend.loadGate = null + releaseFetch.complete(Unit) + // The fetch began pre-ack, so its Success snapshot is legal; with the delete now + // pending, the overlay masks the read as projected absence. Either surface is + // coherent — what matters is that the fetch completed before any push existed. + inflight.await() + assertEquals(emptyList(), backend.receivedPushes) + + users.drain(key) + + assertSame(MutationPresence.Absent, backend.receivedPushes.single().mine) + assertEquals(emptyList(), users.pending(key)) + // Post-ack refetch: begun after the Absent acknowledgement, the backend returns + // FetcherResult.Deleted and the waiter observes Missing — the coherence + // obligation MutationAbsentAck certifies. + val failure = + assertFailsWith { + users.get(key, Freshness.MustBeFresh) + } + assertIs(failure.error) + } finally { + releaseFetch.complete(Unit) + users.close() + } + } + + // Present capture reads bookkeeping status BEFORE the LocalOnly value, and the + // captured metadata is the pre-value snapshot — it may match or lag the value but can never + // lead it under Store's commit ordering. + @Test + fun presentCapture_readsStatusBeforeLocalOnlyAndMetadataNeverLeadsValue() = runTest { + val mutation = RenameMutation() + val backend = FakeBackend() + val log = mutableListOf() + val bookkeeper = + ScriptedBookkeeper( + log = log, + statuses = + ArrayDeque( + listOf( + keyStatus(CaptureMeta(writtenAtEpochMillis = 42L, etag = "first")), + keyStatus(CaptureMeta(writtenAtEpochMillis = 99L, etag = "second")), + ), + ), + ) + val engine = + MutationEngine( + registry = mutation.registry, + server = backend, + bookkeeper = bookkeeper, + keyResolver = MutationsTestKeyResolver, + baseReader = { + log += "base" + "resident" + }, + ) + engine.bind(CaptureNoopHandle) + val key = MutationsTestKey("present-capture") + engine.mutate(key, mutation.ref, "next") + + engine.drain(key) + + // Exactly one ordered bracket: status first, then the LocalOnly base read. + assertEquals(listOf("status", "base"), log) + val push = backend.receivedPushes.single() + assertEquals("resident", assertIs>(push.base).value) + // The FIRST (pre-value) status was captured; the later status never entered the push. + val meta = assertIs(push.baseMeta) + assertEquals(42L, meta.writtenAtEpochMillis) + assertEquals("first", meta.etag) + } + + // Absence is accepted only from the exact loop status -> LocalOnly Missing -> status + // with BOTH bracketing statuses carrying no metadata. + @Test + fun stableAbsentCapture_requiresBothBracketingStatusesWithoutMetadata() = runTest { + lateinit var createRef: MutatorRef + val registry = + mutatorRegistry { + createRef = + create( + id = "create", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { value -> value } + } + val backend = FakeBackend() + val log = mutableListOf() + val bookkeeper = ScriptedBookkeeper(log = log, statuses = ArrayDeque(listOf(null, null))) + val engine = + MutationEngine( + registry = registry, + server = backend, + bookkeeper = bookkeeper, + keyResolver = MutationsTestKeyResolver, + baseReader = { + log += "base" + null + }, + ) + engine.bind(CaptureNoopHandle) + val key = MutationsTestKey("stable-absent") + engine.mutate(key, createRef, "created") + + engine.drain(key) + + assertEquals(listOf("status", "base", "status"), log) + val push = backend.receivedPushes.single() + // Absent is an existence precondition, not an unconditional write (Shared invariants). + assertSame(MutationPresence.Absent, push.base) + assertNull(push.baseMeta) + assertEquals("created", assertIs>(push.mine).value) + } + + @Test + fun absentCapture_retriesWhenEitherBracketingStatusHasMetadata() = runTest { + // Leading bracket carries metadata: the window is open and the loop must retry. + assertAbsentCaptureRetries( + statuses = + ArrayDeque( + listOf( + keyStatus(CaptureMeta(writtenAtEpochMillis = 7L, etag = "leading")), + null, + null, + null, + ), + ), + ) + // Trailing bracket carries metadata: same rejection, same retry. + assertAbsentCaptureRetries( + statuses = + ArrayDeque( + listOf( + null, + keyStatus(CaptureMeta(writtenAtEpochMillis = 8L, etag = "trailing")), + null, + null, + ), + ), + ) + } + + // A FetcherResult.Deleted window — the destructive clear that forgets freshness — + // cannot make a mid-window read pass for stable absence; the loop rejects the bracket whose + // leading status still carried metadata and re-reads. The post-ack facade-level Deleted flow + // is proven by absentAckWithPreAckInflightFetch_thenPostAckRefetchReturnsDeleted. + @Test + fun fetchDeletionCannotCreateFalseStableAbsence() = runTest { + assertWindowCannotCreateFalseStableAbsence { bookkeeper, key -> + bookkeeper.forget(key) + } + } + + // A Store.clear(key) window shares the loop, not only the facade interlock. + @Test + fun keyClearCannotCreateFalseStableAbsence() = runTest { + assertWindowCannotCreateFalseStableAbsence { bookkeeper, key -> + bookkeeper.forget(key) + } + } + + @Test + fun namespaceClearCannotCreateFalseStableAbsence() = runTest { + assertWindowCannotCreateFalseStableAbsence { bookkeeper, _ -> + bookkeeper.forgetNamespace(StoreNamespace("mutations")) + } + } + + @Test + fun clearAllCannotCreateFalseStableAbsence() = runTest { + assertWindowCannotCreateFalseStableAbsence { bookkeeper, _ -> + bookkeeper.forgetAll() + } + } + + @Test + fun ackFailure_leavesIntentPending_andKeepsProjection() = runTest { + val mutation = RenameMutation() + val backend = FakeBackend() + val key = MutationsTestKey("push-failure") + val users = + mutationStore( + registry = mutation.registry, + server = backend, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + ) { + fetcher { backend.load(it) } + } + + try { + assertEquals("base", users.get(key)) + val mutationId = users.mutate(key, mutation.ref, "optimistic") + backend.offline = true + + users.drain(key) + + assertEquals(listOf(mutationId), users.pending(key).map(PendingIntent::mutationId)) + users.stream(key, Freshness.LocalOnly).test { + val optimistic = awaitValue("optimistic") + assertEquals(Origin.OVERLAY, optimistic.origin) + cancelAndIgnoreRemainingEvents() + } + } finally { + users.close() + } + } + + @Test + fun hostileHead_stopsDrainWithoutPushOrRetirement() = runTest { + lateinit var hostile: MutatorRef + lateinit var healthy: MutatorRef + val registry = + mutatorRegistry { + hostile = + mutator( + id = "hostile", + version = 1, + codec = inertArgsCodec(), + stales = noStales(), + ) { _, _ -> error("projection failed") } + healthy = + mutator( + id = "healthy", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { _, value -> MutationPresence.Present(value) } + } + val backend = FakeBackend() + val engine = + MutationEngine( + registry = registry, + server = backend, + baseReader = { "base" }, + ) + val key = MutationsTestKey("hostile-head") + engine.bind(RecordingWriteHandle(mutableListOf())) + val hostileId = engine.mutate(key, hostile, Unit) + val healthyId = engine.mutate(key, healthy, "tail") + + engine.drain(key) + + assertEquals(emptyList(), backend.pushedValues) + assertEquals( + listOf(hostileId, healthyId), + engine.pending(key).map(PendingIntent::mutationId), + ) + } + + @Test + fun unknownHead_stopsDrainWithoutPushOrRetirement() = runTest { + lateinit var healthy: MutatorRef + val registry = + mutatorRegistry { + healthy = + mutator( + id = "healthy", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { _, value -> MutationPresence.Present(value) } + } + val backend = FakeBackend() + val journal = InMemoryMutationJournal() + val engine = + MutationEngine( + registry = registry, + server = backend, + journal = journal, + baseReader = { "base" }, + ) + val key = MutationsTestKey("unknown-head") + val unknownId = "unknown-mutation" + val healthyId = "healthy-mutation" + journal.append( + key.identity(), + JournalEntry( + mutationId = unknownId, + mutatorId = "removed-mutator", + args = Unit, + ), + ) + journal.append( + key.identity(), + JournalEntry( + mutationId = healthyId, + mutatorId = healthy.id, + args = "tail", + ), + ) + engine.bind(RecordingWriteHandle(mutableListOf())) + + engine.drain(key) + + assertEquals(emptyList(), backend.pushedValues) + assertEquals( + listOf(unknownId, healthyId), + engine.pending(key).map(PendingIntent::mutationId), + ) + } + + @Test + fun adoptionFailurePropagates() = runTest { + val mutation = RenameMutation() + val backend = FakeBackend() + var capturedBase: String? = null + val engine = + MutationEngine( + registry = mutation.registry, + server = backend, + baseReader = { capturedBase }, + ) + val key = MutationsTestKey("adoption-failure") + val store = store { fetcher { backend.load(it) } } + capturedBase = store.get(key) + engine.bind(assertNotNull(store.runtime()).writeHandle) + val mutationId = engine.mutate(key, mutation.ref, "pending") + store.close() + + val failure = + assertFailsWith { + engine.drain(key) + } + + assertEquals("Store is closed.", failure.message) + assertEquals( + listOf(mutationId), + engine.pending(key).map(PendingIntent::mutationId), + ) + } + + @Test + fun rawWriteHandleUnreachableThroughFacade() = runTest { + val mutation = RenameMutation() + val backend = FakeBackend() + val users = + mutationStore( + registry = mutation.registry, + server = backend, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + ) { + fetcher { backend.load(it) } + } + val bare = store { fetcher { "base" } } + + try { + assertNull(users.runtime()) + assertNotNull(bare.runtime()) + } finally { + users.close() + bare.close() + } + } + + @Test + fun mutateAfterCloseThrows() = runTest { + val mutation = RenameMutation() + val backend = FakeBackend() + val key = MutationsTestKey("closed") + val users = + mutationStore( + registry = mutation.registry, + server = backend, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + ) { + fetcher { backend.load(it) } + } + users.close() + + val mutateFailure = + assertFailsWith { + users.mutate(key, mutation.ref, "never-appended") + } + val keyedDrainFailure = + assertFailsWith { + users.drain(key) + } + val globalDrainFailure = + assertFailsWith { + users.drain() + } + val pendingFailure = + assertFailsWith { + users.pending(key) + } + val pendingWritesFailure = + assertFailsWith { + users.pendingWrites() + } + val deadLettersFailure = + assertFailsWith { + users.deadLetters() + } + + assertEquals("Store is closed.", mutateFailure.message) + assertEquals("Store is closed.", keyedDrainFailure.message) + assertEquals("Store is closed.", globalDrainFailure.message) + assertEquals("Store is closed.", pendingFailure.message) + assertEquals("Store is closed.", pendingWritesFailure.message) + assertEquals("Store is closed.", deadLettersFailure.message) + } + + @Test + fun drain_progressivePrefixUsesServerEcho() = runTest { + val mutation = AppendMutation() + val backend = + FakeBackend().apply { + pushBehavior = { _, value -> + when (value) { + "base+A" -> + MutationPresentAck( + authoritative = "echo-A", + etag = "etag-a", + canonicalKey = null, + ) + "echo-A+B" -> + MutationPresentAck( + authoritative = "echo-A+B", + etag = "etag-b", + canonicalKey = null, + ) + else -> error("unexpected prefix $value") + } + } + } + val key = MutationsTestKey("progressive-prefix") + val users = + mutationStore( + registry = mutation.registry, + server = backend, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + ) { + fetcher { backend.load(it) } + } + + try { + assertEquals("base", users.get(key)) + users.mutate(key, mutation.ref, "+A") + users.mutate(key, mutation.ref, "+B") + + users.drain(key) + + assertEquals(listOf("base+A", "echo-A+B"), backend.pushedValues) + assertEquals(emptyList(), users.pending(key)) + assertEquals("echo-A+B", users.get(key, Freshness.LocalOnly)) + } finally { + users.close() + } + } + + // Supersedes the walking skeleton's absent-halt (drainOnce_nullPrefixStopsAndLeaves + // DeleteAndCreatePending): delete is drainable — a projected Absent + // pushes with mine = Absent, the Absent acknowledgement adopts through clear, and a queued + // create then re-creates over confirmed absence. + @Test + fun drain_deleteFlowsThroughAbsentAckAndCreateRecreates() = runTest { + lateinit var update: MutatorRef + lateinit var deleteRef: MutatorRef + lateinit var createRef: MutatorRef + val registry = + mutatorRegistry { + update = + mutator( + id = "update", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { _, value -> MutationPresence.Present(value) } + deleteRef = delete(id = "delete", stales = noStales()) + createRef = + mutator( + id = "create", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { _, value -> MutationPresence.Present(value) } + } + val backend = FakeBackend() + val key = MutationsTestKey("delete-then-create") + val users = + mutationStore( + registry = registry, + server = backend, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + ) { + fetcher { backend.load(it) } + } + + try { + assertEquals("base", users.get(key)) + users.mutate(key, update, "updated") + users.mutate(key, deleteRef, Unit) + users.mutate(key, createRef, "recreated") + + users.drain(key) + + assertEquals(listOf("updated", "recreated"), backend.pushedValues) + assertEquals(3, backend.receivedPushes.size) + assertSame(MutationPresence.Absent, backend.receivedPushes[1].mine) + // The create pushed over confirmed absence: its base is the existence precondition. + assertSame(MutationPresence.Absent, backend.receivedPushes[2].base) + assertEquals(emptyList(), users.pending(key)) + assertEquals("recreated", users.get(key, Freshness.LocalOnly)) + } finally { + users.close() + } + } + + @Test + fun concurrentDrain_serializesReadAndAdoption() = runTest { + val mutation = RenameMutation() + val pushStarted = CompletableDeferred() + val releasePush = CompletableDeferred() + val backend = + FakeBackend().apply { + pushBehavior = { _, value -> + pushStarted.complete(Unit) + releasePush.await() + MutationPresentAck(authoritative = value, etag = "etag", canonicalKey = null) + } + } + val key = MutationsTestKey("concurrent-drain") + val users = + mutationStore( + registry = mutation.registry, + server = backend, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + ) { + fetcher { backend.load(it) } + } + + try { + assertEquals("base", users.get(key)) + users.mutate(key, mutation.ref, "pending") + val first = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + users.drain(key) + } + pushStarted.await() + val second = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + users.drain(key) + } + assertFalse(second.isCompleted) + + releasePush.complete(Unit) + first.await() + second.await() + + assertEquals(listOf("pending"), backend.pushedValues) + assertEquals(emptyList(), users.pending(key)) + } finally { + releasePush.complete(Unit) + users.close() + } + } + + @Test + fun pushCancellation_propagatesAndLeavesIntentPending() = runTest { + val mutation = RenameMutation() + val backend = + FakeBackend().apply { + pushBehavior = { _, _ -> throw CancellationException("push cancelled") } + } + val key = MutationsTestKey("push-cancellation") + val users = + mutationStore( + registry = mutation.registry, + server = backend, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + ) { + fetcher { backend.load(it) } + } + + try { + assertEquals("base", users.get(key)) + val mutationId = users.mutate(key, mutation.ref, "pending") + + val failure = + assertFailsWith { + users.drain(key) + } + + assertEquals("push cancelled", failure.message) + assertEquals( + listOf(mutationId), + users.pending(key).map(PendingIntent::mutationId), + ) + } finally { + users.close() + } + } + + /** + * The shared skeleton of the four clear/deletion-window proofs: a leading status that still + * carries metadata brackets a missing value, so the loop must reject that read and re-read + * before accepting stable absence. A single-bracket capture would accept the mid-window read + * and push a false existence precondition. + */ + private suspend fun assertWindowCannotCreateFalseStableAbsence( + window: suspend (Bookkeeper, MutationsTestKey) -> Unit, + ) { + lateinit var createRef: MutatorRef + val registry = + mutatorRegistry { + createRef = + create( + id = "create", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { value -> value } + } + val backend = FakeBackend() + val bookkeeper = MutationBookkeeper() + val key = MutationsTestKey("window-under-test") + bookkeeper.recordSuccess(key, CaptureMeta(writtenAtEpochMillis = 10L, etag = "seeded")) + var baseReads = 0 + val engine = + MutationEngine( + registry = registry, + server = backend, + bookkeeper = bookkeeper, + keyResolver = MutationsTestKeyResolver, + baseReader = { + baseReads += 1 + if (baseReads == 1) { + // The racing operation lands between the leading status and this read: + // the value is already gone while the leading status still shows meta. + window(bookkeeper, key) + } + null + }, + ) + engine.bind(CaptureNoopHandle) + engine.mutate(key, createRef, "recreated") + + engine.drain(key) + + // Two brackets: the mid-window read was rejected, the clean re-read was accepted. + assertEquals(2, baseReads) + val push = backend.receivedPushes.single() + assertSame(MutationPresence.Absent, push.base) + assertNull(push.baseMeta) + } + + private suspend fun assertAbsentCaptureRetries(statuses: ArrayDeque) { + lateinit var createRef: MutatorRef + val registry = + mutatorRegistry { + createRef = + create( + id = "create", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { value -> value } + } + val backend = FakeBackend() + val log = mutableListOf() + val engine = + MutationEngine( + registry = registry, + server = backend, + bookkeeper = ScriptedBookkeeper(log = log, statuses = statuses), + keyResolver = MutationsTestKeyResolver, + baseReader = { + log += "base" + null + }, + ) + engine.bind(CaptureNoopHandle) + val key = MutationsTestKey("absent-retry") + engine.mutate(key, createRef, "created") + + engine.drain(key) + + // First bracket rejected, second accepted: status/base/status twice over. + assertEquals(listOf("status", "base", "status", "status", "base", "status"), log) + assertSame(MutationPresence.Absent, backend.receivedPushes.single().base) + } +} + +private class RenameMutation { + lateinit var ref: MutatorRef + val registry: MutatorRegistry = + mutatorRegistry { + ref = + mutator( + id = "rename", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { _, value -> MutationPresence.Present(value) } + } +} + +private class AppendMutation { + lateinit var ref: MutatorRef + val registry: MutatorRegistry = + mutatorRegistry { + ref = + mutator( + id = "append", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { base, suffix -> + MutationPresence.Present( + ((base as? MutationPresence.Present)?.value).orEmpty() + suffix, + ) + } + } +} + +private class RetireOrderingJournal( + private val events: MutableList, +) : StorageBackedMutationJournal(storage = InMemoryMutationJournalStorage()) { + override suspend fun retire( + key: KeyIdentity, + mutationId: String, + ) { + assertEquals(listOf("apply", "confirmFresh"), events) + super.retire(key, mutationId) + events += "retire" + } +} + +private class ClearOrderingJournal( + private val events: MutableList, +) : StorageBackedMutationJournal(storage = InMemoryMutationJournalStorage()) { + override suspend fun retire( + key: KeyIdentity, + mutationId: String, + ) { + assertEquals(listOf("clear"), events) + super.retire(key, mutationId) + events += "retire" + } +} + +private class RecordingWriteHandle( + private val events: MutableList, +) : StoreWriteHandle { + override suspend fun apply( + key: MutationsTestKey, + value: String, + ) { + events += "apply" + } + + override suspend fun markStale(key: MutationsTestKey) = Unit + + override suspend fun confirmFresh( + key: MutationsTestKey, + etag: String?, + ) { + events += "confirmFresh" + } +} + +/** An Absent acknowledgement must never adopt through the write handle. */ +private object FailingAdoptionHandle : StoreWriteHandle { + override suspend fun apply( + key: MutationsTestKey, + value: String, + ): Unit = fail("an Absent acknowledgement must not call apply") + + override suspend fun markStale(key: MutationsTestKey) = Unit + + override suspend fun confirmFresh( + key: MutationsTestKey, + etag: String?, + ): Unit = fail("an Absent acknowledgement must not call confirmFresh") +} + +private object CaptureNoopHandle : StoreWriteHandle { + override suspend fun apply( + key: MutationsTestKey, + value: String, + ) = Unit + + override suspend fun markStale(key: MutationsTestKey) = Unit + + override suspend fun confirmFresh( + key: MutationsTestKey, + etag: String?, + ) = Unit +} + +private class CaptureMeta( + override val writtenAtEpochMillis: Long, + override val etag: String?, +) : StoreMeta + +private fun keyStatus(meta: StoreMeta?): KeyStatus = + KeyStatus( + meta = meta, + lastSuccessSequence = 1L, + lastFailureAtEpochMillis = null, + consecutiveFailures = 0, + durablyStale = false, + ) + +/** + * Status reads come from a scripted queue and are logged; every other bookkeeping operation is + * inert. WallClock-style scripting keeps the capture brackets deterministic. + */ +private class ScriptedBookkeeper( + private val log: MutableList, + private val statuses: ArrayDeque, +) : Bookkeeper { + override suspend fun recordSuccess( + key: StoreKey, + meta: StoreMeta, + ) = Unit + + override suspend fun recordFailure( + key: StoreKey, + atEpochMillis: Long, + ) = Unit + + override suspend fun status(key: StoreKey): KeyStatus? { + log += "status" + return statuses.removeFirstOrNull() + } + + override suspend fun forget(key: StoreKey) = Unit + + override suspend fun markStale(key: StoreKey) = Unit + + override suspend fun advanceStaleWatermark(namespace: StoreNamespace) = Unit + + override suspend fun advanceGlobalStaleWatermark() = Unit + + override suspend fun forgetNamespace(namespace: StoreNamespace) = Unit + + override suspend fun forgetAll() = Unit +} + +private suspend fun ReceiveTurbine>.awaitValue( + expected: String, +): StoreResult.Data { + while (true) { + val data = awaitData() + if (data.value == expected) return data + } +} + +private suspend fun ReceiveTurbine>.awaitConfirmedValue( + expected: String, +): StoreResult.Data { + while (true) { + val data = awaitData() + if (data.value == expected && data.origin != Origin.OVERLAY) return data + } +} + +// Turbine's 3s default would nest inside the 25s shadow. Raising the Turbine deadline above +// the shadow makes runTest the only effective timeout. +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds // strictly > TEST_TIMEOUT: the shadow must fire first + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } diff --git a/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt new file mode 100644 index 000000000..055f7ae0d --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt @@ -0,0 +1,2092 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, +) + +package org.mobilenativefoundation.store6.mutations + +import app.cash.turbine.ReceiveTurbine +import app.cash.turbine.test +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.selects.select +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import kotlinx.coroutines.withContext +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreException +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.store +import org.mobilenativefoundation.store6.core.seam.StoreWriteHandle +import org.mobilenativefoundation.store6.core.seam.runtime +import org.mobilenativefoundation.store6.mutations.storage.InMemoryMutationJournalStorage +import org.mobilenativefoundation.store6.mutations.storage.MutationAliasState +import org.mobilenativefoundation.store6.mutations.storage.MutationJournalStorage +import org.mobilenativefoundation.store6.mutations.storage.MutationJournalTransaction +import org.mobilenativefoundation.store6.mutations.storage.MutationKeyAliasRecord +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * The same-process canonical alias facade and the facade conversion-error/liveness contract. + * Most tests here exercise the in-memory preview — normalized full-pair redirects, + * sequence-merged sibling re-homing, the lost-wakeup-free per-terminal-identity revision + * signals, and explicit keyed facade routing. + * + * The public-facade projection-fence regression exercises the durable storage seam. The durable + * model's broader contract is proven elsewhere: + * - `MutationJournalContractTest.kt::aliasEdgesAndActivation_roundTripAcrossRestart` + * - `MutationAckOrchestrationTest.kt::ackAliasActivationRebasesQueuedSourceAndTargetSiblings` + * - `MutationConflictTest.kt::serverWinsCancellationAfterCommit_stillPublishesOverlayRevision` + * - `MutationDrainParkingTest.kt::parkingCancellationAfterCommit_stillPublishesOverlayRevisionAndRebasesSuffix` + * - `MutationDrainParkingTest` also covers the durable parks for the alias-protocol violations + * that halt with a normalized `PROTOCOL` carrier here. + */ +class MutationAliasFacadeTest { + @Test + fun presentAckRedirectsQueuedSiblingsAndFutureCalls() = runTest { + val mutations = AliasMutationSet() + val backend = FakeBackend() + backend.redirectEcho("temp-1" to "real-1") + val provisional = MutationsTestKey("temp-1") + val canonical = MutationsTestKey("real-1") + val users = aliasMutationStore(mutations.registry, backend) + + try { + // Sequences 1 and 2 queue at the provisional identity, 3 at the canonical target: + // after the head's redirect activates, the canonical queue must flush 2 then 3 — + // queued siblings from source and target merge by durable client sequence. + users.mutate(provisional, mutations.rename, "draft") + users.mutate(provisional, mutations.append, "+p") + users.mutate(canonical, mutations.append, "+c") + + users.drain(provisional) + + assertEquals( + listOf("temp-1", "real-1", "real-1"), + backend.receivedPushes.map { push -> push.identity.canonicalId }, + ) + assertEquals( + listOf(1L, 2L, 3L), + backend.receivedPushes.map { push -> push.clientSequence }, + ) + assertEquals(listOf("draft", "draft+p", "draft+p+c"), backend.pushedValues) + assertEquals(emptyList(), users.pending(provisional)) + assertEquals(emptyList(), users.pendingWrites()) + + // Future calls route without re-resolving anything at the stale source key. + assertEquals("draft+p+c", users.get(provisional)) + val futureId = users.mutate(provisional, mutations.append, "+later") + val row = users.pendingWrites().single() + assertEquals("real-1", row.canonicalId) + assertEquals("mutations", row.namespace) + assertEquals(futureId, row.mutationId) + } finally { + users.close() + } + } + + @Test + fun streamSwitchesFromProvisionalToCanonicalAfterActivation() = runTest { + val mutations = AliasMutationSet() + val backend = FakeBackend() + backend.redirectEcho("temp-1" to "real-1") + val provisional = MutationsTestKey("temp-1") + val canonical = MutationsTestKey("real-1") + val users = aliasMutationStore(mutations.registry, backend) + + try { + users.stream(provisional).test { + assertEquals("base", awaitData().value) + + users.mutate(provisional, mutations.rename, "draft") + // OVERLAY is the pending-write affordance: the provisional frame is fresh by + // definition, not stale: pending UI keys on origin == OVERLAY. + val optimistic = awaitOverlayValue("draft") + assertEquals(Origin.OVERLAY, optimistic.origin) + assertFalse(optimistic.isStale) + + users.drain(provisional) + + // The live provisional stream re-resolves on the alias-revision bump and swaps + // to delegate.stream(canonical): the confirmed canonical frame arrives + // with a non-overlay origin and no refetch of the provisional identity. + val confirmed = awaitNonOverlayValue("draft") + assertTrue( + confirmed.origin == Origin.SOT || confirmed.origin == Origin.MEMORY, + "expected canonical residence after switch, was ${confirmed.origin}", + ) + cancelAndIgnoreRemainingEvents() + } + + assertEquals(emptyList(), users.pending(provisional)) + assertEquals("draft", users.get(canonical)) + } finally { + users.close() + } + } + + @Test + fun allKeyedFacadeOperationsResolveTerminalIdentity() = runTest { + val mutations = AliasMutationSet() + val backend = FakeBackend() + backend.redirectEcho("temp-1" to "real-1") + val provisional = MutationsTestKey("temp-1") + val canonical = MutationsTestKey("real-1") + val users = aliasMutationStore(mutations.registry, backend) + + try { + users.mutate(provisional, mutations.rename, "draft") + users.drain(provisional) + + // get(P) serves the canonical residence. + assertEquals("draft", users.get(provisional)) + + // invalidate(P) marks the CANONICAL identity stale: a MaxAge read at the canonical + // key must now block for a qualifying fetch. + val fetchesBeforeInvalidate = backend.fetchCount + users.invalidate(provisional) + assertEquals("draft", users.get(canonical, Freshness.MaxAge(notOlderThan = Duration.ZERO))) + assertEquals(fetchesBeforeInvalidate + 1, backend.fetchCount) + + // clear(P) removes the CANONICAL row: a LocalOnly read at the canonical key misses. + users.clear(provisional) + val missing = + assertFailsWith { + users.get(canonical, Freshness.LocalOnly) + } + assertIs(missing.error) + + // mutate(P) journals at the canonical identity and pending(P) reads it back. + val queuedId = users.mutate(provisional, mutations.rename, "requeued") + val row = users.pendingWrites().single() + assertEquals("real-1", row.canonicalId) + assertEquals(queuedId, row.mutationId) + assertEquals( + listOf(queuedId), + users.pending(provisional).map(PendingIntent::mutationId), + ) + + // drain(P) drains the canonical identity. + users.drain(provisional) + assertEquals("real-1", backend.receivedPushes.last().identity.canonicalId) + assertEquals(emptyList(), users.pending(provisional)) + } finally { + users.close() + } + } + + @Test + fun selfAliasIsNoOpAndDuplicateTargetIsIdempotent() = runTest { + val mutations = AliasMutationSet() + + // Self-alias: a Present ack whose canonical key equals the pushed key changes nothing. + val selfBackend = FakeBackend() + selfBackend.pushBehavior = { key, value -> + MutationPresentAck( + authoritative = value, + etag = "self-etag", + canonicalKey = MutationsTestKey(key.canonicalId()), + ) + } + val selfKey = MutationsTestKey("self-1") + val selfHarness = aliasHarness(mutations.registry, selfBackend) + try { + selfHarness.users.mutate(selfKey, mutations.rename, "kept") + selfHarness.users.drain(selfKey) + + assertEquals( + selfKey.identity(), + selfHarness.engine.terminalIdentityOf(selfKey.identity()), + ) + assertEquals(emptyList(), selfHarness.engine.drainFailuresForInspection()) + assertEquals("kept", selfHarness.users.get(selfKey)) + val laterId = selfHarness.users.mutate(selfKey, mutations.rename, "still-here") + assertEquals("self-1", selfHarness.users.pendingWrites().single().canonicalId) + assertEquals(laterId, selfHarness.users.pending(selfKey).single().mutationId) + } finally { + selfHarness.users.close() + } + + // Duplicate equal edge: a retry of the SAME generation acknowledging the SAME canonical + // target reuses the pending edge — idempotent, no protocol failure. (In-memory only: + // the durable path never repushes an ACKED generation; this preview replays it.) + val duplicateBackend = FakeBackend() + duplicateBackend.redirectEcho("temp-2" to "real-2") + val handle = ScriptableWriteHandle() + val provisional = MutationsTestKey("temp-2") + val duplicateHarness = + aliasHarness(mutations.registry, duplicateBackend, engineWriteHandle = handle) + try { + duplicateHarness.users.mutate(provisional, mutations.rename, "draft") + + handle.applyFailure = IllegalStateException("adoption interrupted") + assertFailsWith { + duplicateHarness.users.drain(provisional) + } + // Pending edge: not yet routed, but pinned for retry validation. + assertEquals( + provisional.identity(), + duplicateHarness.engine.terminalIdentityOf(provisional.identity()), + ) + assertEquals(1, duplicateHarness.users.pending(provisional).size) + + handle.applyFailure = null + duplicateHarness.users.drain(provisional) + + assertEquals( + KeyIdentity("mutations", "real-2"), + duplicateHarness.engine.terminalIdentityOf(provisional.identity()), + ) + assertEquals(emptyList(), duplicateHarness.engine.drainFailuresForInspection()) + assertEquals(2, duplicateBackend.receivedPushes.size) + assertEquals( + duplicateBackend.receivedPushes.first().idempotencyKey, + duplicateBackend.receivedPushes.last().idempotencyKey, + ) + assertEquals(emptyList(), duplicateHarness.users.pending(provisional)) + } finally { + duplicateHarness.users.close() + } + } + + @Test + fun chainsResolveTransitively() = runTest { + val mutations = AliasMutationSet() + val backend = FakeBackend() + backend.redirectEcho("temp-1" to "real-1", "real-1" to "real-2") + val provisional = MutationsTestKey("temp-1") + val users = aliasMutationStore(mutations.registry, backend) + + try { + users.mutate(provisional, mutations.rename, "draft") + users.drain(provisional) // temp-1 -> real-1 activates. + + users.mutate(provisional, mutations.append, "+x") // journals at real-1 + users.drain(provisional) // real-1 -> real-2 activates. + + assertEquals( + listOf("temp-1", "real-1"), + backend.receivedPushes.map { push -> push.identity.canonicalId }, + ) + + // Every keyed operation on the original provisional key now resolves through the + // chain to the terminal identity: chains resolve transitively. + assertEquals("draft+x", users.get(provisional)) + val queuedId = users.mutate(provisional, mutations.append, "+y") + val row = users.pendingWrites().single() + assertEquals("real-2", row.canonicalId) + assertEquals( + listOf(queuedId), + users.pending(provisional).map(PendingIntent::mutationId), + ) + users.drain(provisional) + assertEquals("real-2", backend.receivedPushes.last().identity.canonicalId) + } finally { + users.close() + } + } + + @Test + fun presentAckToActiveAlias_adoptsAtTerminalIdentity() = runTest { + val mutations = AliasMutationSet() + val backend = FakeBackend() + backend.redirectEcho("source" to "canonical", "canonical" to "terminal") + val source = MutationsTestKey("source") + val canonical = MutationsTestKey("canonical") + val users = aliasMutationStore(mutations.registry, backend) + + try { + users.mutate(canonical, mutations.rename, "terminal-seed") + users.drain(canonical) + + users.mutate(source, mutations.rename, "source-authoritative") + users.drain(source) + + assertEquals( + "source-authoritative", + users.get(source, Freshness.LocalOnly), + ) + } finally { + users.close() + } + } + + @Test + fun cycleRetargetAndRetryMismatchAreProtocolFailures() = runTest { + suspend fun assertProtocolPark( + journal: StorageBackedMutationJournal, + engine: MutationEngine, + mutationId: String, + expectedDetail: String, + ) { + val snapshot = journal.readDurableSnapshot() + val intent = snapshot.intents.single { row -> row.mutationId == mutationId } + val execution = + snapshot.executions.single { row -> + row.clientId == intent.clientId && row.clientSequence == intent.clientSequence + } + val failures = + snapshot.failures.filter { row -> + row.clientId == intent.clientId && row.clientSequence == intent.clientSequence + } + assertEquals( + org.mobilenativefoundation.store6.mutations.storage.MutationExecutionPhase.PARKED, + execution.phase, + ) + val activeFailureId = + execution.activeFailureId + ?: throw AssertionError("expected PARKED execution to link an active failure") + val failure = failures.single { row -> row.failureId == activeFailureId } + assertEquals(failure.failureId, execution.activeFailureId) + assertEquals(MutationFailureKind.PROTOCOL, failure.kind) + assertEquals(expectedDetail, failure.detail) + assertEquals(1, execution.attempt) + assertTrue(execution.lastAttemptAt != null) + assertEquals( + 1, + failures.count { row -> + row.kind == MutationFailureKind.PROTOCOL && row.detail == expectedDetail + }, + ) + assertEquals(null, execution.retiredAt) + assertTrue( + snapshot.acks.none { row -> + row.clientId == intent.clientId && row.clientSequence == intent.clientSequence + }, + ) + assertTrue( + snapshot.effects.none { row -> + row.clientId == intent.clientId && row.clientSequence == intent.clientSequence + }, + ) + assertTrue( + snapshot.aliases.none { row -> + row.createdByClientId == intent.clientId && + row.createdBySequence == intent.clientSequence + }, + ) + assertTrue( + snapshot.tombstones.none { row -> + row.createdByClientId == intent.clientId && + row.createdBySequence == intent.clientSequence + }, + ) + assertTrue(engine.pendingWrites().none { row -> row.mutationId == mutationId }) + val deadLetter = engine.deadLetters().single { row -> row.mutationId == mutationId } + assertEquals(1, deadLetter.attempts) + assertEquals(MutationFailureKind.PROTOCOL, deadLetter.failure.kind) + assertEquals(expectedDetail, deadLetter.failure.detail) + } + + val armFailures = mutableListOf() + suspend fun runArm( + label: String, + block: suspend () -> Unit, + ) { + try { + block() + } catch (failure: AssertionError) { + armFailures += "$label: ${failure.message.orEmpty()}" + } + } + + // Cycle: establish one ACTIVE durable edge, then park a head whose target closes it. + runArm("cycle") { + val mutations = AliasMutationSet() + val storage = InMemoryMutationJournalStorage() + val journal = + StorageBackedMutationJournal( + storage = storage, + registrations = mutations.registry.registrations, + hydrateOnFirstUse = true, + ) + val backend = FakeBackend() + backend.pushBehavior = { key, value -> + MutationPresentAck( + authoritative = value, + etag = "etag-$value", + canonicalKey = + when { + key.canonicalId() == "temp-a" -> MutationsTestKey("real-a") + value == "cycle-head" -> MutationsTestKey("temp-a") + else -> null + }, + ) + } + val handle = ScriptableWriteHandle() + val engine = + MutationEngine( + registry = mutations.registry, + server = backend, + journal = journal, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + baseReader = { "base" }, + ) + engine.bind(handle) + val source = MutationsTestKey("temp-a") + val target = MutationsTestKey("real-a") + + engine.mutate(source, mutations.rename, "cycle-seed") + engine.drain(source) + assertEquals(target.identity(), engine.terminalIdentityOf(source.identity())) + val appliesBeforeViolation = handle.applied.toList() + val subject = engine.mutate(target, mutations.rename, "cycle-head") + val suffix = engine.mutate(target, mutations.rename, "cycle-suffix") + + engine.drain(target) + + assertProtocolPark(journal, engine, subject, ALIAS_FAILURE_DETAIL_CYCLE) + val after = journal.readDurableSnapshot() + val suffixIntent = after.intents.single { row -> row.mutationId == suffix } + assertEquals( + org.mobilenativefoundation.store6.mutations.storage.MutationExecutionPhase.RETIRED, + after.executions.single { row -> + row.clientId == suffixIntent.clientId && + row.clientSequence == suffixIntent.clientSequence + }.phase, + ) + assertEquals(appliesBeforeViolation + ("real-a" to "cycle-suffix"), handle.applied) + assertTrue(handle.applied.none { (_, value) -> value == "cycle-head" }) + assertEquals(1, after.aliases.size) + assertEquals( + org.mobilenativefoundation.store6.mutations.storage.MutationAliasState.ACTIVE, + after.aliases.single().state, + ) + assertEquals(emptyList(), engine.pending(target)) + assertEquals(1, engine.deadLetters().size) + val pushCount = backend.receivedPushes.size + engine.drain(target) + assertEquals(pushCount, backend.receivedPushes.size) + } + + // Retarget: a separate durable client legally leaves the first edge PENDING at ACKED. + // This client can then prove that a different generation cannot replace that target. + runArm("retarget") { + val mutations = AliasMutationSet() + val storage = InMemoryMutationJournalStorage() + val seedJournal = + StorageBackedMutationJournal( + storage = storage, + registrations = mutations.registry.registrations, + clientId = "retarget-seed-client", + hydrateOnFirstUse = true, + ) + val seedBackend = FakeBackend() + seedBackend.redirectEcho("temp-b" to "real-b1") + val seedHandle = ScriptableWriteHandle() + seedHandle.applyFailure = IllegalStateException("hold pending alias at ACKED") + val seedEngine = + MutationEngine( + registry = mutations.registry, + server = seedBackend, + journal = seedJournal, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + baseReader = { "base" }, + clientId = "retarget-seed-client", + ) + seedEngine.bind(seedHandle) + val source = MutationsTestKey("temp-b") + seedEngine.mutate(source, mutations.rename, "retarget-seed") + assertFailsWith { seedEngine.drain(source) } + val seeded = seedJournal.readDurableSnapshot() + assertEquals( + org.mobilenativefoundation.store6.mutations.storage.MutationExecutionPhase.ACKED, + seeded.executions.single().phase, + ) + assertEquals( + org.mobilenativefoundation.store6.mutations.storage.MutationAliasState.PENDING, + seeded.aliases.single().state, + ) + + val journal = + StorageBackedMutationJournal( + storage = storage, + registrations = mutations.registry.registrations, + hydrateOnFirstUse = true, + ) + val backend = FakeBackend() + backend.pushBehavior = { _, value -> + MutationPresentAck( + authoritative = value, + etag = "etag-$value", + canonicalKey = + when (value) { + "retarget-head" -> MutationsTestKey("real-b2") + "retarget-suffix" -> MutationsTestKey("real-b1") + else -> null + }, + ) + } + val handle = ScriptableWriteHandle() + val engine = + MutationEngine( + registry = mutations.registry, + server = backend, + journal = journal, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + baseReader = { "base" }, + ) + engine.bind(handle) + val subject = engine.mutate(source, mutations.rename, "retarget-head") + val suffix = engine.mutate(source, mutations.rename, "retarget-suffix") + + engine.drain(source) + + assertProtocolPark(journal, engine, subject, ALIAS_FAILURE_DETAIL_RETARGET) + val after = journal.readDurableSnapshot() + val suffixIntent = after.intents.single { row -> row.mutationId == suffix } + assertEquals( + org.mobilenativefoundation.store6.mutations.storage.MutationExecutionPhase.RETIRED, + after.executions.single { row -> + row.clientId == suffixIntent.clientId && + row.clientSequence == suffixIntent.clientSequence + }.phase, + ) + assertEquals(listOf("real-b1" to "retarget-suffix"), handle.applied) + assertTrue(handle.applied.none { (_, value) -> value == "retarget-head" }) + assertEquals(1, after.aliases.size) + assertEquals( + org.mobilenativefoundation.store6.mutations.storage.MutationAliasState.ACTIVE, + after.aliases.single().state, + ) + assertEquals(emptyList(), engine.pendingWrites()) + assertEquals(1, engine.deadLetters().size) + val pushCount = backend.receivedPushes.size + engine.drain(source) + assertEquals(pushCount, backend.receivedPushes.size) + } + + // Retry mismatch: the first target is observed, but its ACK transaction rolls back. + // The exact generation replays once; changing its target must park before any receipt. + runArm("retry-mismatch") { + val mutations = AliasMutationSet() + val backing = InMemoryMutationJournalStorage() + var failAckTransaction = true + val storage = + object : + org.mobilenativefoundation.store6.mutations.storage.MutationJournalStorage { + override suspend fun transaction( + block: ( + org.mobilenativefoundation.store6.mutations.storage.MutationJournalTransaction, + ) -> R, + ): R = + backing.transaction { transaction -> + var insertedAck = false + val observing = + object : + org.mobilenativefoundation.store6.mutations.storage.MutationJournalTransaction by + transaction { + override fun insertAck( + record: org.mobilenativefoundation.store6.mutations.storage.MutationAckRecord, + ) { + transaction.insertAck(record) + insertedAck = true + } + } + val result = block(observing) + if (failAckTransaction && insertedAck) { + failAckTransaction = false + throw IllegalStateException("ack transaction interrupted") + } + result + } + } + val journal = + StorageBackedMutationJournal( + storage = storage, + registrations = mutations.registry.registrations, + hydrateOnFirstUse = true, + ) + val backend = FakeBackend() + var target = "real-c1" + backend.pushBehavior = { _, value -> + MutationPresentAck( + authoritative = value, + etag = "etag-$value", + canonicalKey = + if (value == "retry-head") MutationsTestKey(target) else null, + ) + } + val handle = ScriptableWriteHandle() + val engine = + MutationEngine( + registry = mutations.registry, + server = backend, + journal = journal, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + baseReader = { "base" }, + ) + engine.bind(handle) + val source = MutationsTestKey("temp-c") + val subject = engine.mutate(source, mutations.rename, "retry-head") + val suffix = engine.mutate(source, mutations.rename, "retry-suffix") + + assertFailsWith { engine.drain(source) } + assertFalse(failAckTransaction) + val rolledBack = journal.readDurableSnapshot() + val subjectIntent = rolledBack.intents.single { row -> row.mutationId == subject } + assertEquals( + org.mobilenativefoundation.store6.mutations.storage.MutationExecutionPhase.INFLIGHT, + rolledBack.executions.single { row -> + row.clientId == subjectIntent.clientId && + row.clientSequence == subjectIntent.clientSequence + }.phase, + ) + assertTrue( + rolledBack.acks.none { row -> row.clientSequence == subjectIntent.clientSequence }, + ) + assertTrue(rolledBack.aliases.isEmpty()) + assertTrue(journal.runtimeSnapshot().aliases.isEmpty()) + target = "real-c2" + + engine.drain(source) + + assertEquals( + backend.receivedPushes[0].idempotencyKey, + backend.receivedPushes[1].idempotencyKey, + ) + assertProtocolPark( + journal, + engine, + subject, + ALIAS_FAILURE_DETAIL_RETRY_TARGET_MISMATCH, + ) + val after = journal.readDurableSnapshot() + val suffixIntent = after.intents.single { row -> row.mutationId == suffix } + assertEquals( + org.mobilenativefoundation.store6.mutations.storage.MutationExecutionPhase.RETIRED, + after.executions.single { row -> + row.clientId == suffixIntent.clientId && + row.clientSequence == suffixIntent.clientSequence + }.phase, + ) + assertEquals(listOf("temp-c" to "retry-suffix"), handle.applied) + assertTrue(handle.applied.none { (_, value) -> value == "retry-head" }) + assertTrue(after.aliases.isEmpty()) + assertEquals(emptyList(), engine.pending(source)) + assertEquals(1, engine.deadLetters().size) + val pushCount = backend.receivedPushes.size + engine.drain(source) + assertEquals(pushCount, backend.receivedPushes.size) + } + + assertEquals(emptyList(), armFailures, armFailures.joinToString(separator = "\n")) + } + + @Test + fun namespaceCollisionAndCrossNamespaceAliasAreRejected() = runTest { + val mutations = AliasMutationSet() + val backend = FakeBackend() + backend.pushBehavior = { key, value -> + MutationPresentAck( + authoritative = value, + etag = "etag", + canonicalKey = + when (key.canonicalId()) { + // The cross-namespace claim: same canonical id, foreign namespace. + "temp-x" -> MutationsTestKey("real-1", StoreNamespace("foreign")) + "temp-1" -> MutationsTestKey("real-1") + else -> null + }, + ) + } + val harness = aliasHarness(mutations.registry, backend) + val users = harness.users + + try { + // Cross-namespace rejection: normalized PROTOCOL failure, intent halts, no adoption. + val crossKey = MutationsTestKey("temp-x") + val crossIntent = users.mutate(crossKey, mutations.rename, "never-adopted") + users.drain(crossKey) + + val failure = harness.engine.drainFailuresForInspection().single() + assertEquals(MutationFailureKind.PROTOCOL, failure.kind) + assertEquals(ALIAS_FAILURE_DETAIL_CROSS_NAMESPACE, failure.detail) + assertEquals( + listOf(crossIntent), + users.pending(crossKey).map(PendingIntent::mutationId), + ) + assertEquals( + crossKey.identity(), + harness.engine.terminalIdentityOf(crossKey.identity()), + ) + val neverAdopted = + assertFailsWith { + users.get(crossKey, Freshness.LocalOnly) + } + assertIs(neverAdopted.error) + + // No canonical-id collision across namespaces: aliasing (mutations, temp-1) leaves + // the full pair (foreign, temp-1) unrouted — durable identity is the exact pair. + val provisional = MutationsTestKey("temp-1") + users.mutate(provisional, mutations.rename, "draft") + users.drain(provisional) + assertEquals( + KeyIdentity("mutations", "real-1"), + harness.engine.terminalIdentityOf(provisional.identity()), + ) + val foreignTwin = MutationsTestKey("temp-1", StoreNamespace("foreign")) + assertEquals( + foreignTwin.identity(), + harness.engine.terminalIdentityOf(foreignTwin.identity()), + ) + val foreignIntent = users.mutate(foreignTwin, mutations.rename, "independent") + val foreignRow = + users.pendingWrites().single { row -> row.mutationId == foreignIntent } + assertEquals("foreign", foreignRow.namespace) + assertEquals("temp-1", foreignRow.canonicalId) + } finally { + users.close() + } + } + + @Test + fun streamResolverFailureEmitsConversionAndRetriesOnAliasSignalWithoutCompleting() = runTest { + val mutations = AliasMutationSet() + val backend = FakeBackend() + backend.redirectEcho("temp-1" to "real-1") + var resolverFailure: Throwable? = null + val harness = + aliasHarness( + mutations.registry, + backend, + keyResolver = + MutationKeyResolver { identity -> + resolverFailure?.let { throw it } + MutationsTestKeyResolver.resolve(identity) + }, + ) + val users = harness.users + val provisional = MutationsTestKey("temp-1") + + try { + users.mutate(provisional, mutations.rename, "draft") + users.drain(provisional) + // Force the alias route through the resolver: drop the cached canonical K and make + // the resolver fail. + harness.engine.clearLiveKeyCache() + val boom = IllegalStateException("canonical lookup offline") + resolverFailure = boom + + users.stream(provisional).test { + // Exactly one sanctioned conversion error: live, no delegation to the stale + // source key, no completion. + val error = assertIs(awaitItem()) + val conversion = assertIs(error.error) + assertSame(boom, conversion.cause) + assertFalse(error.servedStale) + expectNoEvents() + + // A new collection attempts resolution immediately and fails independently. + users.stream(provisional).test { + val freshAttempt = assertIs(awaitItem()) + assertIs(freshAttempt.error) + cancelAndIgnoreRemainingEvents() + } + + // A failing non-stream facade attempt bumps the identity's revision: the waiting + // stream retries once and re-emits exactly one conversion error. + assertFailsWith { users.get(provisional) } + assertIs(assertIs(awaitItem()).error) + expectNoEvents() + + // A successful validated resolution wakes the stream: it resolves the terminal + // identity and collects the canonical delegate stream. + resolverFailure = null + assertEquals("draft", users.get(provisional)) + val confirmed = awaitNonOverlayValue("draft") + assertTrue( + confirmed.origin == Origin.SOT || confirmed.origin == Origin.MEMORY, + "expected canonical residence after retry, was ${confirmed.origin}", + ) + cancelAndIgnoreRemainingEvents() + } + } finally { + users.close() + } + } + + @Test + fun suspendingFacadeResolverFailureThrowsConversion() = runTest { + val mutations = AliasMutationSet() + val backend = FakeBackend() + backend.redirectEcho("temp-1" to "real-1") + var resolverFailure: Throwable? = null + var resolveToNull = false + val harness = + aliasHarness( + mutations.registry, + backend, + keyResolver = + MutationKeyResolver { identity -> + resolverFailure?.let { throw it } + if (resolveToNull) null else MutationsTestKeyResolver.resolve(identity) + }, + ) + val users = harness.users + val provisional = MutationsTestKey("temp-1") + + try { + users.mutate(provisional, mutations.rename, "draft") + users.drain(provisional) + harness.engine.clearLiveKeyCache() + val boom = IllegalStateException("canonical lookup offline") + resolverFailure = boom + + // One resolution attempt each; the sanctioned conversion-backed exception retains + // the thrown cause in the immediate public exception only. + val fromGet = assertFailsWith { users.get(provisional) } + assertIs(fromGet.error) + assertSame(boom, fromGet.cause) + + val fromInvalidate = assertFailsWith { users.invalidate(provisional) } + assertIs(fromInvalidate.error) + + // Keyed drain parks rather than throws: an unresolvable pre-ack terminal records + // the normalized IDENTITY carrier and returns normally. The durable engine converts + // such a halt into a park. + val failuresBeforeDrain = harness.engine.drainFailuresForInspection().size + users.drain(provisional) + val drainFailure = harness.engine.drainFailuresForInspection().last() + assertEquals(failuresBeforeDrain + 1, harness.engine.drainFailuresForInspection().size) + assertEquals(MutationFailureKind.IDENTITY, drainFailure.kind) + assertEquals(DRAIN_FAILURE_DETAIL_KEYED_TERMINAL_UNRESOLVED, drainFailure.detail) + + // Resolver null has no cause. + resolverFailure = null + resolveToNull = true + val fromClear = assertFailsWith { users.clear(provisional) } + assertIs(fromClear.error) + assertEquals(null, fromClear.cause) + } finally { + users.close() + } + } + + @Test + fun mutateResolverFailureFailsBeforeJournalAppend() = runTest { + val mutations = AliasMutationSet() + val backend = FakeBackend() + backend.redirectEcho("temp-1" to "real-1") + var resolverFailure: Throwable? = null + val harness = + aliasHarness( + mutations.registry, + backend, + keyResolver = + MutationKeyResolver { identity -> + resolverFailure?.let { throw it } + MutationsTestKeyResolver.resolve(identity) + }, + ) + val users = harness.users + val provisional = MutationsTestKey("temp-1") + + try { + users.mutate(provisional, mutations.rename, "draft") + users.drain(provisional) + harness.engine.clearLiveKeyCache() + resolverFailure = IllegalStateException("canonical lookup offline") + + // mutate resolves BEFORE the append: failure creates no intent anywhere. + val failure = + assertFailsWith { + users.mutate(provisional, mutations.append, "+never") + } + assertIs(failure.error) + assertEquals(emptyList(), users.pendingWrites()) + assertEquals(emptyList(), users.pending(provisional)) + } finally { + users.close() + } + } + + @Test + fun pendingQueriesDurableIdentityWithoutResolvingK() = runTest { + val mutations = AliasMutationSet() + val backend = FakeBackend() + backend.redirectEcho("temp-1" to "real-1") + var resolverCalls = 0 + val harness = + aliasHarness( + mutations.registry, + backend, + keyResolver = + MutationKeyResolver { identity -> + resolverCalls += 1 + MutationsTestKeyResolver.resolve(identity) + }, + ) + val users = harness.users + val provisional = MutationsTestKey("temp-1") + val canonical = MutationsTestKey("real-1") + + try { + users.mutate(provisional, mutations.rename, "draft") + users.drain(provisional) + val queuedId = users.mutate(canonical, mutations.append, "+queued") + harness.engine.clearLiveKeyCache() + + // pending(P) follows the alias as durable identity pairs only: no K is + // reconstructed, so a dead resolver cannot fail this inspection. + val rows = users.pending(provisional) + assertEquals(listOf(queuedId), rows.map(PendingIntent::mutationId)) + assertEquals("real-1", rows.single().canonicalId) + assertEquals(0, resolverCalls) + } finally { + users.close() + } + } + + @Test + fun cancellationAfterAliasCommit_stillPublishesRevisionAndSwitchesLiveStream() = runTest { + val mutations = AliasMutationSet() + val backend = FakeBackend() + backend.redirectEcho("temp-1" to "real-1") + val journal = InMemoryMutationJournal() + val handle = ScriptableWriteHandle() + val harness = + aliasHarness( + mutations.registry, + backend, + journal = journal, + engineWriteHandle = handle, + nonSuspendingBaseValue = "base", + ) + val engine = harness.engine + val users = harness.users + val blockerKey = MutationsTestKey("alias-signal-blocker") + val provisional = MutationsTestKey("temp-1") + val canonical = MutationsTestKey("real-1") + val provisionalObserved = CompletableDeferred() + val canonicalObserved = CompletableDeferred() + val collector = + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + engine.changes.collect { key -> + when { + key === provisional -> provisionalObserved.complete(key) + key === canonical || key.canonicalId() == "real-1" -> + canonicalObserved.complete(key) + } + } + } + + try { + users.stream(provisional).test { + assertEquals("base", awaitData().value) + + // The blocker's committed enqueue signal occupies the replay buffer so the + // drain's post-commit emission must suspend inside the NonCancellable handoff — + // the same staging as the two preserved accepted-state regressions, + // MutationOverlayTest.cancelledMutate_afterAppendStillPublishesKeyChange and + // cancelledDrain_afterRetireStillPublishesKeyChange. + users.mutate(blockerKey, mutations.rename, "first") + // Stage the provisional intent without an enqueue signal. + journal.append( + provisional.identity(), + JournalEntry( + mutationId = "alias-intent", + mutatorId = mutations.rename.id, + args = "draft", + ), + ) + + val cancelledDrain = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.drain(provisional) + } + assertFalse(cancelledDrain.isCompleted) + + // The in-memory commit and the stateful revision handoff completed BEFORE the + // suspended emission: the alias is active and the revision is published even + // though the drain caller is about to be cancelled: the accepted-state handoff + // completes under NonCancellable. + assertEquals( + canonical.identity(), + engine.terminalIdentityOf(provisional.identity()), + ) + assertTrue(engine.aliasRevision(provisional.identity()).value > 0L) + + cancelledDrain.cancel() + testScheduler.runCurrent() + cancelledDrain.join() + assertTrue(cancelledDrain.isCancelled) + + // Both accepted-state key-change signals survived the cancellation. Their + // emissions completed inside the NonCancellable handoff before join() returned, + // but the observing collector is dispatched separately, so await its + // observations instead of asserting synchronous completion. + provisionalObserved.await() + canonicalObserved.await() + + // The live provisional stream switched to the canonical delegate stream. + val confirmed = awaitNonOverlayValue("draft") + assertTrue(confirmed.origin != Origin.OVERLAY) + cancelAndIgnoreRemainingEvents() + } + + assertEquals(emptyList(), users.pending(provisional)) + } finally { + try { + collector.cancelAndJoin() + } finally { + users.close() + } + } + } + + @Test + fun aliasActivationCacheHandoff_neverExposesPartialSiblingProjection() = runTest { + val mutations = AliasMutationSet() + val backend = FakeBackend() + val source = MutationsTestKey("handoff-source") + val target = MutationsTestKey("handoff-target") + val secondPushEntered = CompletableDeferred() + val releaseSecondPush = CompletableDeferred() + var pushCount = 0 + backend.pushBehavior = { key, value -> + pushCount += 1 + if (pushCount == 2) { + secondPushEntered.complete(Unit) + releaseSecondPush.await() + } + MutationPresentAck( + authoritative = value, + etag = "etag-$pushCount", + canonicalKey = target.takeIf { key.canonicalId() == source.canonicalId() }, + ) + } + val journal = AliasRetireGateJournal(mutations.registry.registrations) + val harness = aliasHarness(mutations.registry, backend, journal = journal) + val users = harness.users + + val headId = users.mutate(source, mutations.rename, "head") + val siblingId = users.mutate(source, mutations.append, "+tail") + val drain = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + users.drain(source) + } + + try { + // Before the repair this pauses after ACTIVE routing publication but before retire; + // after the repair it pauses on sibling push, after the one-snapshot handoff. + select { + journal.retireEntered.onAwait { } + secondPushEntered.onAwait { } + } + + val pending = users.pending(source).map(PendingIntent::mutationId) + var observed: StoreResult.Data? = null + users.stream(source, Freshness.LocalOnly).test { + observed = awaitData() + cancelAndIgnoreRemainingEvents() + } + + assertEquals(listOf(siblingId), pending) + assertEquals("head+tail", checkNotNull(observed).value) + assertEquals(Origin.OVERLAY, checkNotNull(observed).origin) + assertTrue(headId !in pending) + } finally { + journal.releaseRetire.complete(Unit) + releaseSecondPush.complete(Unit) + drain.await() + users.close() + } + } + + @Test + fun publicFacadeFreshAttachAfterAliasPublication_firstFrameIsCompleteOverlay() = runTest { + val mutations = AliasMutationSet() + val backend = FakeBackend() + val source = MutationsTestKey("fence-source") + val target = MutationsTestKey("fence-target") + val secondPushEntered = CompletableDeferred() + val releaseSecondPush = CompletableDeferred() + var pushCount = 0 + backend.pushBehavior = { key, value -> + pushCount += 1 + if (pushCount == 2) { + secondPushEntered.complete(Unit) + releaseSecondPush.await() + } + MutationPresentAck( + authoritative = value, + etag = "etag-$pushCount", + canonicalKey = target.takeIf { key.canonicalId() == source.canonicalId() }, + ) + } + val gatedStorage = + AliasActivationCommitGateStorage( + source = source.identity(), + target = target.identity(), + ) + val users = + mutationStore( + registry = mutations.registry, + server = backend, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + ) { + fetcher { backend.load(it) } + journalStorage(gatedStorage) + } + + users.mutate(source, mutations.rename, "head") + users.mutate(source, mutations.append, "+tail") + val drain = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + users.drain(source) + } + + try { + gatedStorage.commitEntered.await() + val targetHeadObserved = CompletableDeferred() + val targetResidence = + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + users.stream(target, Freshness.LocalOnly).collect { result -> + if (result is StoreResult.Data && result.value == "head") { + targetHeadObserved.complete(Unit) + } + } + } + try { + targetHeadObserved.await() + assertFalse(targetResidence.isCompleted) + + var freshAttach: Deferred>? = null + val stampObserver = + backgroundScope.launch( + context = Dispatchers.Unconfined, + start = CoroutineStart.UNDISPATCHED, + ) { + val applied = users.appliedProjectionStamp(target.identity()) + users + .emittedProjectionStamp(target.identity()) + .first { emitted -> emitted > applied.value } + freshAttach = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + users + .stream(source, Freshness.LocalOnly) + .first { result -> result is StoreResult.Data } + } + testScheduler.runCurrent() + } + try { + gatedStorage.releaseCommit.complete(Unit) + secondPushEntered.await() + + stampObserver.join() + val firstData = + assertIs>( + checkNotNull(freshAttach).await(), + ) + assertEquals("head+tail", firstData.value) + assertEquals(Origin.OVERLAY, firstData.origin) + } finally { + try { + stampObserver.cancelAndJoin() + } finally { + freshAttach?.cancelAndJoin() + } + } + } finally { + targetResidence.cancelAndJoin() + } + } finally { + gatedStorage.releaseCommit.complete(Unit) + releaseSecondPush.complete(Unit) + try { + drain.await() + } finally { + users.close() + } + } + } + + @Test + fun freshSourceAttachWhenRuntimeAliasBecomesActive_firstFrameIsCompleteOverlay() = runTest { + val mutations = AliasMutationSet() + val backend = FakeBackend() + val source = MutationsTestKey("runtime-fence-source") + val target = MutationsTestKey("runtime-fence-target") + val secondPushEntered = CompletableDeferred() + val releaseSecondPush = CompletableDeferred() + var pushCount = 0 + backend.pushBehavior = { key, value -> + pushCount += 1 + if (pushCount == 2) { + secondPushEntered.complete(Unit) + releaseSecondPush.await() + } + MutationPresentAck( + authoritative = value, + etag = "etag-$pushCount", + canonicalKey = target.takeIf { key.canonicalId() == source.canonicalId() }, + ) + } + val gatedStorage = + AliasActivationCommitGateStorage( + source = source.identity(), + target = target.identity(), + ) + val journal = + StorageBackedMutationJournal( + storage = gatedStorage, + registrations = mutations.registry.registrations, + hydrateOnFirstUse = true, + ) + val harness = aliasHarness(mutations.registry, backend, journal = journal) + val users = harness.users + + users.mutate(source, mutations.rename, "head") + users.mutate(source, mutations.append, "+tail") + val drain = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + users.drain(source) + } + + try { + gatedStorage.commitEntered.await() + val targetHeadObserved = CompletableDeferred() + val targetResidence = + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + users.stream(target, Freshness.LocalOnly).collect { result -> + if (result is StoreResult.Data && result.value == "head") { + targetHeadObserved.complete(Unit) + } + } + } + try { + targetHeadObserved.await() + assertFalse(targetResidence.isCompleted) + + var freshAttach: Deferred>? = null + val snapshotObserver = + backgroundScope.launch( + context = Dispatchers.Unconfined, + start = CoroutineStart.UNDISPATCHED, + ) { + journal.runtimeState.snapshots.first { snapshot -> + if ( + snapshot.aliases[source.identity()]?.state == + AliasEdgeState.ACTIVE + ) { + freshAttach = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + users + .stream(source, Freshness.LocalOnly) + .first { result -> result is StoreResult.Data } + } + testScheduler.runCurrent() + true + } else { + false + } + } + } + try { + gatedStorage.releaseCommit.complete(Unit) + secondPushEntered.await() + + snapshotObserver.join() + val firstData = + assertIs>( + checkNotNull(freshAttach).await(), + ) + assertEquals("head+tail", firstData.value) + assertEquals(Origin.OVERLAY, firstData.origin) + } finally { + try { + snapshotObserver.cancelAndJoin() + } finally { + freshAttach?.cancelAndJoin() + } + } + } finally { + targetResidence.cancelAndJoin() + } + } finally { + gatedStorage.releaseCommit.complete(Unit) + releaseSecondPush.complete(Unit) + try { + drain.await() + } finally { + users.close() + } + } + } + + @Test + fun concurrentEnqueueDuringAliasActivation_rehomesAtTerminalIdentity() = runTest { + val mutations = AliasMutationSet() + val backend = FakeBackend() + val source = MutationsTestKey("enqueue-source") + val target = MutationsTestKey("enqueue-target") + backend.redirectEcho(source.canonicalId() to target.canonicalId()) + val applyEntered = CompletableDeferred() + val releaseApply = CompletableDeferred() + val handle = + object : StoreWriteHandle { + override suspend fun apply( + key: MutationsTestKey, + value: String, + ) { + applyEntered.complete(Unit) + releaseApply.await() + } + + override suspend fun markStale(key: MutationsTestKey) = Unit + + override suspend fun confirmFresh( + key: MutationsTestKey, + etag: String?, + ) = Unit + } + val journal = LateAppendGateJournal(mutations.registry.registrations) + val harness = + aliasHarness( + registry = mutations.registry, + backend = backend, + journal = journal, + engineWriteHandle = handle, + nonSuspendingBaseValue = "base", + ) + val users = harness.users + + users.mutate(source, mutations.rename, "head") + val drain = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + users.drain(source) + } + applyEntered.await() + + journal.armLateAppend() + val lateAppend = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + users.mutate(source, mutations.append, "+late") + } + journal.lateAppendEntered.await() + + // Current code can publish ACTIVE while append is paused after facade resolution. A + // shared enqueue/activation gate may instead keep the drain queued behind that append. + releaseApply.complete(Unit) + testScheduler.runCurrent() + journal.releaseLateAppend.complete(Unit) + + val lateId = lateAppend.await() + drain.await() + try { + assertEquals(target.identity(), harness.engine.terminalIdentityOf(source.identity())) + assertEquals( + listOf(lateId), + users.pending(source).map(PendingIntent::mutationId), + ) + assertEquals( + listOf(target.canonicalId()), + users.pendingWrites().map(PendingIntent::canonicalId), + ) + assertEquals("head+late", harness.engine.projectAll(target, "head")) + } finally { + users.close() + } + } + + @Test + fun suspendingResolver_doesNotHoldGlobalMutationGate() = runTest { + val mutations = AliasMutationSet() + val backend = FakeBackend() + val source = MutationsTestKey("resolver-source") + val target = MutationsTestKey("resolver-target") + backend.redirectEcho(source.canonicalId() to target.canonicalId()) + val storage = InMemoryMutationJournalStorage() + val first = + aliasHarness( + registry = mutations.registry, + backend = backend, + journal = + StorageBackedMutationJournal( + storage = storage, + registrations = mutations.registry.registrations, + hydrateOnFirstUse = true, + ), + ) + first.users.mutate(source, mutations.rename, "head") + first.users.drain(source) + first.users.close() + + val resolverEntered = CompletableDeferred() + val releaseResolver = CompletableDeferred() + val reopened = + aliasHarness( + registry = mutations.registry, + backend = backend, + journal = + StorageBackedMutationJournal( + storage = storage, + registrations = mutations.registry.registrations, + hydrateOnFirstUse = true, + ), + keyResolver = + MutationKeyResolver { identity -> + if (identity.canonicalId == target.canonicalId()) { + resolverEntered.complete(Unit) + releaseResolver.await() + } + MutationsTestKeyResolver.resolve(identity) + }, + nonSuspendingBaseValue = "base", + ) + val blocked = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + reopened.users.mutate(source, mutations.append, "+blocked") + } + resolverEntered.await() + val unrelated = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + reopened.users.mutate( + MutationsTestKey("resolver-unrelated"), + mutations.append, + "+free", + ) + } + testScheduler.runCurrent() + + try { + assertTrue(unrelated.isCompleted) + } finally { + releaseResolver.complete(Unit) + blocked.await() + unrelated.await() + reopened.users.close() + } + } + + @Test + fun pendingInspection_usesOneRuntimeSnapshotAcrossAliasHandoff() = runTest { + val staged = stageInspectionSplit(InspectionSplit.KEYED) + try { + assertTrue( + staged.siblingId in + staged.users.pending(staged.source).map(PendingIntent::mutationId), + ) + } finally { + staged.drain.cancelAndJoin() + staged.users.close() + } + } + + @Test + fun pendingWritesInspection_usesOneRuntimeSnapshotAcrossAliasHandoff() = runTest { + val staged = stageInspectionSplit(InspectionSplit.GLOBAL) + try { + assertTrue( + staged.siblingId in staged.users.pendingWrites().map(PendingIntent::mutationId), + ) + } finally { + staged.drain.cancelAndJoin() + staged.users.close() + } + } + + @Test + fun closeWhileWaitingForResolver_cancelsStreamPromptlyAndReleasesRetrySubscription() = runTest { + val mutations = AliasMutationSet() + val backend = FakeBackend() + backend.redirectEcho("temp-1" to "real-1") + var resolverFailure: Throwable? = null + val harness = + aliasHarness( + mutations.registry, + backend, + keyResolver = + MutationKeyResolver { identity -> + resolverFailure?.let { throw it } + MutationsTestKeyResolver.resolve(identity) + }, + ) + val users = harness.users + val provisional = MutationsTestKey("temp-1") + val canonicalIdentity = KeyIdentity("mutations", "real-1") + + users.mutate(provisional, mutations.rename, "draft") + users.drain(provisional) + harness.engine.clearLiveKeyCache() + resolverFailure = IllegalStateException("canonical lookup offline") + + val collected = mutableListOf>() + val streamCollector = + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + users.stream(provisional).collect { result -> collected += result } + } + testScheduler.runCurrent() + + // The stream emitted its one conversion error and is suspended on the retry + // subscription for the terminal identity's revision signal. + assertEquals(1, collected.size) + assertIs(assertIs(collected.single()).error) + assertTrue(harness.engine.resolutionPulseSubscriptions(canonicalIdentity) >= 1) + + users.close() + testScheduler.runCurrent() + + // close() cancels the waiting collector promptly and releases the retry subscription. + assertTrue(streamCollector.isCompleted) + assertTrue(streamCollector.isCancelled) + assertEquals(0, harness.engine.resolutionPulseSubscriptions(canonicalIdentity)) + assertEquals(1, collected.size) + } + + /** + * The alias-facing ack-variant rule, standalone: a retry of one generation idempotency + * key must return the same canonical target or the intent halts as a protocol violation. + * The staging mirrors the retry-mismatch arm of + * [cycleRetargetAndRetryMismatchAreProtocolFailures] with the ack-variant assertions. + */ + @Test + fun retryOfGenerationWithDifferentCanonicalTarget_isProtocolFailure() = runTest { + val mutations = AliasMutationSet() + val backing = InMemoryMutationJournalStorage() + var failAckTransaction = true + val storage = + object : org.mobilenativefoundation.store6.mutations.storage.MutationJournalStorage { + override suspend fun transaction( + block: ( + org.mobilenativefoundation.store6.mutations.storage.MutationJournalTransaction, + ) -> R, + ): R = + backing.transaction { transaction -> + var insertedAck = false + val observing = + object : + org.mobilenativefoundation.store6.mutations.storage.MutationJournalTransaction by + transaction { + override fun insertAck( + record: org.mobilenativefoundation.store6.mutations.storage.MutationAckRecord, + ) { + transaction.insertAck(record) + insertedAck = true + } + } + val result = block(observing) + if (failAckTransaction && insertedAck) { + failAckTransaction = false + throw IllegalStateException("ack transaction interrupted") + } + result + } + } + val journal = + StorageBackedMutationJournal( + storage = storage, + registrations = mutations.registry.registrations, + hydrateOnFirstUse = true, + ) + val backend = FakeBackend() + var canonicalTarget = "real-1" + backend.pushBehavior = { _, value -> + MutationPresentAck( + authoritative = value, + etag = "etag", + canonicalKey = MutationsTestKey(canonicalTarget), + ) + } + val handle = ScriptableWriteHandle() + val engine = + MutationEngine( + registry = mutations.registry, + server = backend, + journal = journal, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + baseReader = { "base" }, + ) + engine.bind(handle) + val provisional = MutationsTestKey("temp-1") + + // The first target is validated, but its acknowledgement transaction rolls back. The + // durable generation remains INFLIGHT and therefore replays with the exact idempotency + // key; the process-local target receipt still makes a different retry target illegal. + val intent = engine.mutate(provisional, mutations.rename, "draft") + assertFailsWith { engine.drain(provisional) } + assertFalse(failAckTransaction) + val rolledBack = journal.readDurableSnapshot() + val intentRow = rolledBack.intents.single { row -> row.mutationId == intent } + assertEquals( + org.mobilenativefoundation.store6.mutations.storage.MutationExecutionPhase.INFLIGHT, + rolledBack.executions.single { row -> + row.clientId == intentRow.clientId && row.clientSequence == intentRow.clientSequence + }.phase, + ) + assertTrue(rolledBack.acks.isEmpty()) + assertTrue(rolledBack.aliases.isEmpty()) + assertTrue(rolledBack.tombstones.isEmpty()) + assertTrue(journal.runtimeSnapshot().aliases.isEmpty()) + + // The retry of the SAME idempotency key acknowledges a different canonical target. + canonicalTarget = "real-9" + engine.drain(provisional) + + assertEquals(2, backend.receivedPushes.size) + assertEquals( + backend.receivedPushes.first().idempotencyKey, + backend.receivedPushes.last().idempotencyKey, + ) + val snapshot = journal.readDurableSnapshot() + val execution = + snapshot.executions.single { row -> + row.clientId == intentRow.clientId && row.clientSequence == intentRow.clientSequence + } + val failures = + snapshot.failures.filter { row -> + row.clientId == intentRow.clientId && row.clientSequence == intentRow.clientSequence + } + assertEquals( + org.mobilenativefoundation.store6.mutations.storage.MutationExecutionPhase.PARKED, + execution.phase, + ) + val activeFailureId = + execution.activeFailureId + ?: throw AssertionError("expected PARKED execution to link an active failure") + val failure = failures.single { row -> row.failureId == activeFailureId } + assertEquals(failure.failureId, execution.activeFailureId) + assertEquals(MutationFailureKind.PROTOCOL, failure.kind) + assertEquals(ALIAS_FAILURE_DETAIL_RETRY_TARGET_MISMATCH, failure.detail) + assertEquals(1, execution.attempt) + assertTrue(execution.lastAttemptAt != null) + assertEquals( + 1, + failures.count { row -> + row.kind == MutationFailureKind.PROTOCOL && + row.detail == ALIAS_FAILURE_DETAIL_RETRY_TARGET_MISMATCH + }, + ) + assertEquals(null, execution.retiredAt) + assertTrue(snapshot.acks.isEmpty()) + assertTrue(snapshot.aliases.isEmpty()) + assertTrue(snapshot.tombstones.isEmpty()) + assertTrue(snapshot.effects.isEmpty()) + assertTrue(journal.runtimeSnapshot().aliases.isEmpty()) + assertTrue(handle.applied.isEmpty()) + assertTrue(engine.pendingWrites().none { row -> row.mutationId == intent }) + val deadLetter = engine.deadLetters().single() + assertEquals(intent, deadLetter.mutationId) + assertEquals(1, deadLetter.attempts) + assertEquals(MutationFailureKind.PROTOCOL, deadLetter.failure.kind) + assertEquals(ALIAS_FAILURE_DETAIL_RETRY_TARGET_MISMATCH, deadLetter.failure.detail) + assertEquals( + provisional.identity(), + engine.terminalIdentityOf(provisional.identity()), + ) + engine.drain(provisional) + assertEquals(2, backend.receivedPushes.size) + } +} + +// --------------------------------------------------------------------------------------------- +// Fixtures local to the alias facade tests. +// --------------------------------------------------------------------------------------------- + +private class AliasMutationSet { + lateinit var rename: MutatorRef + lateinit var append: MutatorRef + val registry: MutatorRegistry = + mutatorRegistry { + rename = + mutator( + id = "rename", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { _, value -> MutationPresence.Present(value) } + append = + mutator( + id = "append", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { base, suffix -> + MutationPresence.Present( + ((base as? MutationPresence.Present)?.value).orEmpty() + suffix, + ) + } + } +} + +/** + * Scripts echo acknowledgements whose canonical key redirects each listed provisional id to its + * canonical id; every other key acknowledges with an unchanged identity. + */ +private fun FakeBackend.redirectEcho(vararg redirects: Pair) { + val table = redirects.toMap() + pushBehavior = { key, value -> + MutationPresentAck( + authoritative = value, + etag = "server-etag", + canonicalKey = table[key.canonicalId()]?.let(::MutationsTestKey), + ) + } +} + +/** The public entry point, used where no engine door is needed. */ +private fun aliasMutationStore( + registry: MutatorRegistry, + backend: FakeBackend, +): MutationStore = + mutationStore( + registry = registry, + server = backend, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + ) { + fetcher { backend.load(it) } + } + +private class AliasHarness( + val engine: MutationEngine, + val users: MutationStore, +) + +private class AliasActivationCommitGateStorage( + private val source: KeyIdentity, + private val target: KeyIdentity, +) : MutationJournalStorage { + private val delegate = InMemoryMutationJournalStorage() + val commitEntered = CompletableDeferred() + val releaseCommit = CompletableDeferred() + + override suspend fun transaction(block: (MutationJournalTransaction) -> R): R { + var activationObserved = false + val result = + delegate.transaction { transaction -> + block( + object : MutationJournalTransaction by transaction { + override fun advanceAlias(record: MutationKeyAliasRecord) { + transaction.advanceAlias(record) + if ( + record.state == MutationAliasState.ACTIVE && + record.sourceNamespace == source.namespace && + record.sourceCanonicalId == source.canonicalId && + record.targetNamespace == target.namespace && + record.targetCanonicalId == target.canonicalId + ) { + activationObserved = true + } + } + }, + ) + } + if (activationObserved) { + commitEntered.complete(Unit) + withContext(NonCancellable) { releaseCommit.await() } + } + return result + } +} + +private class AliasRetireGateJournal( + registrations: Map>, +) : StorageBackedMutationJournal( + storage = InMemoryMutationJournalStorage(), + registrations = registrations, + hydrateOnFirstUse = true, + ) { + val retireEntered = CompletableDeferred() + val releaseRetire = CompletableDeferred() + + override suspend fun retire( + key: KeyIdentity, + mutationId: String, + ) { + retireEntered.complete(Unit) + releaseRetire.await() + super.retire(key, mutationId) + } +} + +private class LateAppendGateJournal( + registrations: Map>, +) : StorageBackedMutationJournal( + storage = InMemoryMutationJournalStorage(), + registrations = registrations, + hydrateOnFirstUse = true, + ) { + val lateAppendEntered = CompletableDeferred() + val releaseLateAppend = CompletableDeferred() + private var gateNextAppend = false + + fun armLateAppend() { + gateNextAppend = true + } + + override suspend fun append( + key: KeyIdentity, + entry: JournalEntry, + ): String { + if (gateNextAppend) { + gateNextAppend = false + lateAppendEntered.complete(Unit) + releaseLateAppend.await() + } + return super.append(key, entry) + } +} + +private enum class InspectionSplit { + KEYED, + GLOBAL, +} + +private class SnapshotSwapOnReadJournal( + registrations: Map>, +) : StorageBackedMutationJournal( + storage = InMemoryMutationJournalStorage(), + registrations = registrations, + hydrateOnFirstUse = true, + ) { + private var split: InspectionSplit? = null + private lateinit var source: KeyIdentity + private lateinit var target: KeyIdentity + private lateinit var retiredMutationId: String + + fun arm( + split: InspectionSplit, + source: KeyIdentity, + target: KeyIdentity, + retiredMutationId: String, + ) { + this.split = split + this.source = source + this.target = target + this.retiredMutationId = retiredMutationId + } + + override fun pendingSnapshot(key: KeyIdentity): List> { + if (split == InspectionSplit.KEYED) swapToActiveSnapshot() + return super.pendingSnapshot(key) + } + + override fun identities(): Set { + val before = super.identities() + if (split == InspectionSplit.GLOBAL) swapToActiveSnapshot() + return before + } + + private fun swapToActiveSnapshot() { + if (split == null) return + split = null + val current = runtimeState.snapshots.value + val edge = checkNotNull(current.aliases[source]) + val siblings = + current.entries[source] + .orEmpty() + .filterNot { entry -> entry.mutationId == retiredMutationId } + val targetRows = + (current.entries[target].orEmpty() + siblings) + .distinctBy(JournalEntry::mutationId) + .sortedBy(JournalEntry::durableClientSequence) + val movedEntries = + if (targetRows.isEmpty()) { + current.entries - source - target + } else { + current.entries - source + (target to targetRows) + } + runtimeState.snapshots.value = + current.copy( + entries = movedEntries, + aliases = current.aliases + (source to edge.copy(state = AliasEdgeState.ACTIVE)), + ) + } +} + +private class InspectionSplitHarness( + val users: MutationStore, + val source: MutationsTestKey, + val siblingId: String, + val drain: Deferred, +) + +private suspend fun TestScope.stageInspectionSplit( + split: InspectionSplit, +): InspectionSplitHarness { + val mutations = AliasMutationSet() + val backend = FakeBackend() + val source = MutationsTestKey("inspection-source-${split.name.lowercase()}") + val target = MutationsTestKey("inspection-target-${split.name.lowercase()}") + backend.redirectEcho(source.canonicalId() to target.canonicalId()) + val applyEntered = CompletableDeferred() + val releaseApply = CompletableDeferred() + val handle = + object : StoreWriteHandle { + override suspend fun apply( + key: MutationsTestKey, + value: String, + ) { + applyEntered.complete(Unit) + releaseApply.await() + } + + override suspend fun markStale(key: MutationsTestKey) = Unit + + override suspend fun confirmFresh( + key: MutationsTestKey, + etag: String?, + ) = Unit + } + val journal = SnapshotSwapOnReadJournal(mutations.registry.registrations) + val harness = + aliasHarness( + registry = mutations.registry, + backend = backend, + journal = journal, + engineWriteHandle = handle, + nonSuspendingBaseValue = "base", + ) + val headId = harness.users.mutate(source, mutations.rename, "head") + val siblingId = harness.users.mutate(source, mutations.append, "+tail") + val drain = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + harness.users.drain(source) + } + applyEntered.await() + journal.arm(split, source.identity(), target.identity(), headId) + return InspectionSplitHarness(harness.users, source, siblingId, drain) +} + +/** + * Mirrors the [mutationStore] factory wiring — same delegate construction cycle, captured + * runtime, and narrowed facade — while retaining the engine door these tests must reach + * (drain-failure carriers, live-key cache, revision subscriptions) plus two staging levers: + * [engineWriteHandle] substitutes the bound write handle and [nonSuspendingBaseValue] replaces + * the delegate `LocalOnly` base reader so a drain pass has no suspension point before its + * accepted-state emission (the cancellation regression's staging). The factory path itself is + * exercised by the redirect/switch/chain routing tests above. + */ +private fun aliasHarness( + registry: MutatorRegistry, + backend: FakeBackend, + keyResolver: MutationKeyResolver = MutationsTestKeyResolver, + journal: StorageBackedMutationJournal = InMemoryMutationJournal(), + engineWriteHandle: StoreWriteHandle? = null, + nonSuspendingBaseValue: String? = null, +): AliasHarness { + var boundDelegate: Store? = null + val engine = + MutationEngine( + registry = registry, + server = backend, + journal = journal, + keyResolver = keyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + baseReader = + if (nonSuspendingBaseValue != null) { + { nonSuspendingBaseValue } + } else { + { key -> checkNotNull(boundDelegate).localBaseOrNull(key) } + }, + absentAdoption = { key -> checkNotNull(boundDelegate).clear(key) }, + ) + val delegate = + store { + fetcher { backend.load(it) } + overlay(engine.overlay) + } + boundDelegate = delegate + val runtime = checkNotNull(delegate.runtime()) + engine.bind(engineWriteHandle ?: runtime.writeHandle) + return AliasHarness( + engine = engine, + users = MutationStore(delegate, engine, runtime.keyEvents), + ) +} + +private suspend fun Store.localBaseOrNull( + key: MutationsTestKey, +): String? = + try { + get(key, Freshness.LocalOnly) + } catch (failure: StoreException) { + if (failure.error is StoreError.Missing) null else throw failure + } + +private class ScriptableWriteHandle : StoreWriteHandle { + var applyFailure: Throwable? = null + val applied = mutableListOf>() + + override suspend fun apply( + key: MutationsTestKey, + value: String, + ) { + applyFailure?.let { throw it } + applied += key.canonicalId() to value + } + + override suspend fun markStale(key: MutationsTestKey) = Unit + + override suspend fun confirmFresh( + key: MutationsTestKey, + etag: String?, + ) = Unit +} + +private suspend fun ReceiveTurbine>.awaitOverlayValue( + expected: String, +): StoreResult.Data { + while (true) { + val data = awaitData() + if (data.value == expected && data.origin == Origin.OVERLAY) return data + } +} + +private suspend fun ReceiveTurbine>.awaitNonOverlayValue( + expected: String, +): StoreResult.Data { + while (true) { + val data = awaitData() + if (data.value == expected && data.origin != Origin.OVERLAY) return data + } +} + +// Turbine's 3s default would nest inside the 25s shadow. Raising the Turbine deadline above +// the shadow makes runTest the only effective timeout. +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds // strictly > TEST_TIMEOUT: the shadow must fire first + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } diff --git a/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationBackoffTest.kt b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationBackoffTest.kt new file mode 100644 index 000000000..4a8a53e70 --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationBackoffTest.kt @@ -0,0 +1,609 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, + kotlinx.coroutines.ExperimentalCoroutinesApi::class, +) + +package org.mobilenativefoundation.store6.mutations + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.seam.StoreWriteHandle +import org.mobilenativefoundation.store6.core.seam.WallClock +import org.mobilenativefoundation.store6.mutations.storage.InMemoryMutationJournalStorage +import org.mobilenativefoundation.store6.mutations.storage.MutationAttemptRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionPhase as StoredPhase +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +class MutationBackoffTest { + @Test + fun eligibilityDerivesFromDurableAttemptFactsUnderExponentialCapAndFullJitter() = runTest { + val observedWindows = mutableListOf() + + for (attempt in listOf(1, 2, 3, 16)) { + val storage = InMemoryMutationJournalStorage() + val mutations = BackoffMutations() + val server = + BackoffServer( + failuresRemaining = attempt, + retainRetiredRows = true, + ) + val clock = SchedulerBackoffClock { testScheduler.currentTime } + val computed = backoffWindow(attempt) + val seed = seedWithPositiveFirstJitter(computed) + val expectedJitter = Random(seed).nextLong(computed + 1L) + val key = MutationsTestKey("eligibility-$attempt") + val writer = openBackoffEngine(storage, mutations, server, clock, Random(seed)) + + writer.mutate(key, mutations.append, "+mine") + repeat(attempt) { writer.drain(key) } + val failed = backoffState(storage) + assertEquals(attempt, failed.attempt) + assertEquals(testScheduler.currentTime, failed.lastAttemptAt) + observedWindows += computed + + server.failuresRemaining = 0 + writer.clearLiveKeyCache() + testScheduler.advanceTimeBy(expectedJitter - 1L) + val early = openBackoffEngine(storage, mutations, server, clock, Random(seed)) + early.drain() + assertEquals(attempt, server.pushes.size, "attempt $attempt pushed before eligibility") + assertEquals(StoredPhase.READY, backoffState(storage).phase) + + testScheduler.advanceTimeBy(1L) + val atBoundary = openBackoffEngine(storage, mutations, server, clock, Random(seed)) + atBoundary.drain() + assertEquals(attempt + 1, server.pushes.size) + assertEquals(StoredPhase.RETIRED, backoffState(storage).phase) + assertTrue(expectedJitter in 0L..computed) + } + + assertEquals(listOf(1_000L, 2_000L, 4_000L, 300_000L), observedWindows) + + val attemptZeroStorage = InMemoryMutationJournalStorage() + val attemptZeroMutations = BackoffMutations() + val attemptZeroServer = BackoffServer(retainRetiredRows = true) + val attemptZeroRandom = CountingBackoffRandom(Random(47)) + val attemptZeroKey = MutationsTestKey("attempt-zero-no-rng") + val attemptZero = + openBackoffEngine( + attemptZeroStorage, + attemptZeroMutations, + attemptZeroServer, + SchedulerBackoffClock { testScheduler.currentTime }, + attemptZeroRandom, + ) + attemptZero.mutate(attemptZeroKey, attemptZeroMutations.append, "+mine") + attemptZero.clearLiveKeyCache() + attemptZero.drain() + assertEquals(0, attemptZeroRandom.nextBitsCalls, "attempt zero must not consume RNG") + assertEquals(StoredPhase.RETIRED, backoffState(attemptZeroStorage).phase) + + val refreshStorage = InMemoryMutationJournalStorage() + val refreshMutations = BackoffMutations() + val refreshServer = BackoffServer(failuresRemaining = 1) + val refreshClock = SchedulerBackoffClock { testScheduler.currentTime } + val refreshKey = MutationsTestKey("refresh-required-eligibility") + val refreshWriter = + openBackoffEngine( + refreshStorage, + refreshMutations, + refreshServer, + refreshClock, + Random(48), + ) + refreshWriter.mutate(refreshKey, refreshMutations.append, "+mine") + refreshWriter.drain(refreshKey) + seedRefreshRequired(refreshStorage, testScheduler.currentTime) + val refreshBefore = backoffState(refreshStorage) + var resolverCalls = 0 + val refreshRandom = + CountingBackoffRandom(Random(seedWithPositiveFirstJitter(backoffWindow(2)))) + val refreshReader = + openBackoffEngine( + refreshStorage, + refreshMutations, + refreshServer, + refreshClock, + refreshRandom, + resolver = MutationKeyResolver { + resolverCalls += 1 + throw IllegalStateException("REFRESH_REQUIRED resolved before eligibility") + }, + ) + refreshReader.drain() + assertTrue(refreshRandom.nextBitsCalls > 0, "REFRESH_REQUIRED must derive jitter") + assertEquals(0, resolverCalls, "REFRESH_REQUIRED must preflight before resolution") + assertEquals(refreshBefore, backoffState(refreshStorage)) + } + + @Test + fun jitterIsRedrawnPerPassAndNeverPersisted() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = BackoffMutations() + val server = + BackoffServer( + failuresRemaining = 1, + retainRetiredRows = true, + ) + val clock = SchedulerBackoffClock { testScheduler.currentTime } + + val captureStorage = InMemoryMutationJournalStorage() + val captureKey = MutationsTestKey("capture-cancellation-attempt-facts") + val captureEngine = + openBackoffEngine( + captureStorage, + mutations, + BackoffServer(), + clock, + Random(40), + baseReader = { throw CancellationException("capture cancelled") }, + ) + captureEngine.mutate(captureKey, mutations.append, "+mine") + assertFailsWith { captureEngine.drain(captureKey) } + val captureCancelled = backoffState(captureStorage) + assertEquals(StoredPhase.UNPREPARED, captureCancelled.phase) + assertEquals(0, captureCancelled.attempt) + assertNull(captureCancelled.lastAttemptAt) + assertNull(captureCancelled.activeFailureKind) + assertEquals(0, captureCancelled.failureCount) + val redraw = seedWithDescendingFirstTwoJitters(1_000L) + val key = MutationsTestKey("redrawn-per-pass") + val engine = openBackoffEngine(storage, mutations, server, clock, Random(redraw.seed)) + + engine.mutate(key, mutations.append, "+mine") + engine.drain(key) + server.failuresRemaining = 0 + engine.clearLiveKeyCache() + testScheduler.advanceTimeBy(redraw.second) + + engine.drain() + assertEquals(1, server.pushes.size) + assertEquals(StoredPhase.READY, backoffState(storage).phase) + + val reopened = openBackoffEngine(storage, mutations, server, clock, Random(redraw.seed)) + reopened.drain() + assertEquals(1, server.pushes.size, "restart must not reuse a persisted jitter") + assertEquals(StoredPhase.READY, backoffState(storage).phase) + + engine.drain() + assertEquals(2, server.pushes.size) + assertEquals(StoredPhase.RETIRED, backoffState(storage).phase) + } + + @Test + fun restartRecomputesEligibilityFromDurableFactsAlone() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = BackoffMutations() + val server = + BackoffServer( + failuresRemaining = 1, + retainRetiredRows = true, + ) + val clock = SchedulerBackoffClock { testScheduler.currentTime } + val seeds = seedsStraddling(1_000L, 500L) + val key = MutationsTestKey("restart-facts-only") + val writer = openBackoffEngine(storage, mutations, server, clock, Random(seeds.highSeed)) + + writer.mutate(key, mutations.append, "+mine") + writer.drain(key) + server.failuresRemaining = 0 + writer.clearLiveKeyCache() + testScheduler.advanceTimeBy(500L) + + val beforeRestart = + openBackoffEngine(storage, mutations, server, clock, Random(seeds.highSeed)) + beforeRestart.drain() + assertEquals(1, server.pushes.size) + assertEquals(StoredPhase.READY, backoffState(storage).phase) + + val afterRestart = + openBackoffEngine(storage, mutations, server, clock, Random(seeds.lowSeed)) + afterRestart.drain() + assertEquals(2, server.pushes.size) + assertEquals(StoredPhase.RETIRED, backoffState(storage).phase) + } + + @Test + fun attemptFactsAdvanceOnlyOnCompletedTransportAttempts() = runTest { + val mutations = BackoffMutations() + val clock = SchedulerBackoffClock { testScheduler.currentTime } + + val resolverStorage = InMemoryMutationJournalStorage() + val resolverEngine = + openBackoffEngine( + resolverStorage, + mutations, + BackoffServer(), + clock, + Random(41), + resolver = MutationKeyResolver { throw IllegalStateException("resolver failed") }, + ) + val resolverKey = MutationsTestKey("resolver-attempt-facts") + resolverEngine.mutate(resolverKey, mutations.append, "+mine") + resolverEngine.clearLiveKeyCache() + resolverEngine.drain() + val resolverPark = backoffState(resolverStorage) + assertEquals(StoredPhase.PARKED, resolverPark.phase) + assertEquals(0, resolverPark.attempt) + assertNull(resolverPark.lastAttemptAt) + assertEquals(MutationFailureKind.IDENTITY, resolverPark.activeFailureKind) + + val codecStorage = InMemoryMutationJournalStorage() + val cancellingServer = BackoffServer() + cancellingServer.pushBehavior = { throw CancellationException("capture completed") } + val codecKey = MutationsTestKey("codec-attempt-facts") + val capturing = + openBackoffEngine( + codecStorage, + mutations, + cancellingServer, + clock, + Random(42), + valueCodecVersion = 99, + valueCodec = VersionBoundStringCodec(99), + ) + capturing.mutate(codecKey, mutations.append, "+mine") + assertFailsWith { capturing.drain(codecKey) } + val captured = backoffState(codecStorage) + assertEquals(StoredPhase.INFLIGHT, captured.phase) + assertEquals(0, captured.attempt) + assertNull(captured.lastAttemptAt) + assertNull(captured.activeFailureKind) + + val codecPark = + openBackoffEngine( + codecStorage, + mutations, + BackoffServer(), + clock, + Random(43), + valueCodec = VersionBoundStringCodec(1), + ) + codecPark.drain() + val parked = backoffState(codecStorage) + assertEquals(StoredPhase.PARKED, parked.phase) + assertEquals(0, parked.attempt) + assertNull(parked.lastAttemptAt) + assertEquals(MutationFailureKind.CODEC, parked.activeFailureKind) + + val transportStorage = InMemoryMutationJournalStorage() + val transportServer = BackoffServer(failuresRemaining = 1) + val transport = + openBackoffEngine(transportStorage, mutations, transportServer, clock, Random(44)) + val transportKey = MutationsTestKey("transport-attempt-facts") + transport.mutate(transportKey, mutations.append, "+mine") + transport.drain(transportKey) + val failedTransport = backoffState(transportStorage) + assertEquals(StoredPhase.READY, failedTransport.phase) + assertEquals(1, failedTransport.attempt) + assertEquals(testScheduler.currentTime, failedTransport.lastAttemptAt) + assertEquals(1, failedTransport.failureCount) + } + + @Test + fun notYetEligibleEntry_isSkippedByGlobalDrainWithoutFailureOrPark() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = BackoffMutations() + val server = + BackoffServer( + failuresRemaining = 1, + retainRetiredRows = true, + ) + val clock = SchedulerBackoffClock { testScheduler.currentTime } + val seed = seedWithPositiveFirstJitter(1_000L) + val key = MutationsTestKey("global-skip") + val engine = openBackoffEngine(storage, mutations, server, clock, Random(seed)) + + engine.mutate(key, mutations.append, "+mine") + engine.drain(key) + val beforeSkip = backoffState(storage) + server.failuresRemaining = 0 + engine.clearLiveKeyCache() + + engine.drain() + val afterSkip = backoffState(storage) + assertEquals(1, server.pushes.size) + assertEquals(beforeSkip, afterSkip) + assertEquals(StoredPhase.READY, afterSkip.phase) + + testScheduler.advanceTimeBy(1_001L) + engine.drain() + assertEquals(2, server.pushes.size) + assertEquals(StoredPhase.RETIRED, backoffState(storage).phase) + } + + @Test + fun keyedDrainOverridesBackoffEligibility() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = BackoffMutations() + val server = + BackoffServer( + failuresRemaining = 1, + retainRetiredRows = true, + ) + val clock = SchedulerBackoffClock { testScheduler.currentTime } + val key = MutationsTestKey("keyed-override") + val engine = openBackoffEngine(storage, mutations, server, clock, Random(45)) + + engine.mutate(key, mutations.append, "+mine") + engine.drain(key) + assertEquals(StoredPhase.READY, backoffState(storage).phase) + server.failuresRemaining = 0 + + engine.drain(key) + assertEquals(2, server.pushes.size) + assertEquals(StoredPhase.RETIRED, backoffState(storage).phase) + } +} + +private const val BACKOFF_CLIENT_ID: String = "backoff-client" + +private class BackoffMutations { + lateinit var append: MutatorRef + + val registry: MutatorRegistry = + mutatorRegistry { + append = + mutator( + id = "backoff-append", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { base, suffix -> + MutationPresence.Present( + ((base as? MutationPresence.Present)?.value).orEmpty() + suffix, + ) + } + } +} + +private class BackoffServer( + var failuresRemaining: Int = 0, + private val retainRetiredRows: Boolean = false, +) : MutationServer { + val pushes = mutableListOf>() + var pushBehavior: suspend (MutationPush) -> Unit = {} + + override suspend fun push( + request: MutationPush, + ): MutationAck { + pushes += request + pushBehavior(request) + if (failuresRemaining > 0) { + failuresRemaining -= 1 + throw IllegalStateException("scripted completed transport failure") + } + return when (val mine = request.mine) { + is MutationPresence.Present -> + MutationPresentAck(mine.value, "etag-${pushes.size}", null) + MutationPresence.Absent -> MutationAbsentAck("etag-${pushes.size}") + } + } + + override suspend fun retire(request: MutationRetirement): MutationRetirementAck = + MutationRetirementAck( + confirmedThroughSequence = + if (retainRetiredRows) { + 0L + } else { + request.retiredThroughSequence + }, + ) +} + +private class VersionBoundStringCodec( + private val supportedVersion: Int, +) : MutationCodec { + override fun encode(value: String): ByteArray = value.encodeToByteArray() + + override fun decode(version: Int, bytes: ByteArray): String { + require(version == supportedVersion) { + "Only codec version $supportedVersion is available; was $version." + } + return bytes.decodeToString() + } +} + +private class SchedulerBackoffClock( + private val now: () -> Long, +) : WallClock { + override fun nowEpochMillis(): Long = now() +} + +private object BackoffNoopHandle : StoreWriteHandle { + override suspend fun apply(key: MutationsTestKey, value: String) = Unit + + override suspend fun markStale(key: MutationsTestKey) = Unit + + override suspend fun confirmFresh(key: MutationsTestKey, etag: String?) = Unit +} + +private fun openBackoffEngine( + storage: InMemoryMutationJournalStorage, + mutations: BackoffMutations, + server: BackoffServer, + wallClock: WallClock, + random: Random, + resolver: MutationKeyResolver = MutationsTestKeyResolver, + valueCodecVersion: Int = 1, + valueCodec: MutationCodec = VersionBoundStringCodec(1), + baseReader: suspend (MutationsTestKey) -> String? = { "base" }, +): MutationEngine = + MutationEngine( + registry = mutations.registry, + server = server, + journal = + StorageBackedMutationJournal( + storage = storage, + registrations = mutations.registry.registrations, + clientId = BACKOFF_CLIENT_ID, + hydrateOnFirstUse = true, + ), + keyResolver = resolver, + valueCodecVersion = valueCodecVersion, + valueCodec = valueCodec, + baseReader = baseReader, + wallClock = wallClock, + backoffRandom = random, + clientId = BACKOFF_CLIENT_ID, + ).also { engine -> engine.bind(BackoffNoopHandle) } + +private data class BackoffState( + val phase: StoredPhase, + val attempt: Int, + val lastAttemptAt: Long?, + val activeFailureKind: MutationFailureKind?, + val failureCount: Int, +) + +private suspend fun backoffState(storage: InMemoryMutationJournalStorage): BackoffState = + storage.transaction { transaction -> + val execution = transaction.executions(BACKOFF_CLIENT_ID).single() + val failures = transaction.failures(BACKOFF_CLIENT_ID) + BackoffState( + phase = execution.phase, + attempt = execution.attempt, + lastAttemptAt = execution.lastAttemptAt, + activeFailureKind = + execution.activeFailureId?.let { failureId -> + assertNotNull(failures.singleOrNull { it.failureId == failureId }).kind + }, + failureCount = failures.size, + ) + } + +private fun backoffWindow(attempt: Int): Long { + require(attempt >= 1) + var delay = 1_000L + repeat(attempt - 1) { + delay = minOf(300_000L, delay * 2L) + } + return delay +} + +private class CountingBackoffRandom( + private val delegate: Random, +) : Random() { + var nextBitsCalls: Int = 0 + private set + + override fun nextBits(bitCount: Int): Int { + nextBitsCalls += 1 + return delegate.nextBits(bitCount) + } +} + +private suspend fun seedRefreshRequired( + storage: InMemoryMutationJournalStorage, + occurredAt: Long, +) { + storage.transaction { transaction -> + val execution = transaction.executions(BACKOFF_CLIENT_ID).single() + val attempt = transaction.attempts(BACKOFF_CLIENT_ID).single() + transaction.advanceExecution( + MutationExecutionRecord( + clientId = execution.clientId, + clientSequence = execution.clientSequence, + phase = StoredPhase.INFLIGHT, + currentGeneration = execution.currentGeneration, + attempt = execution.attempt, + lastAttemptAt = execution.lastAttemptAt, + activeFailureId = null, + retiredAt = null, + ), + ) + transaction.recordConflictReceipt( + MutationAttemptRecord( + clientId = attempt.clientId, + clientSequence = attempt.clientSequence, + generation = attempt.generation, + effectiveNamespace = attempt.effectiveNamespace, + effectiveCanonicalId = attempt.effectiveCanonicalId, + valueCodecVersion = attempt.valueCodecVersion, + basePresence = attempt.basePresence, + baseBlob = attempt.baseBlob, + minePresence = attempt.minePresence, + mineBlob = attempt.mineBlob, + preconditionMetaPresent = attempt.preconditionMetaPresent, + preconditionWrittenAt = attempt.preconditionWrittenAt, + preconditionEtag = attempt.preconditionEtag, + advertisedRetiredThroughSequence = attempt.advertisedRetiredThroughSequence, + generationIdempotencyKey = attempt.generationIdempotencyKey, + preparedAt = attempt.preparedAt, + conflictMetaPresent = false, + conflictWrittenAt = null, + conflictEtag = null, + conflictReceivedAt = occurredAt, + ), + ) + transaction.advanceExecution( + MutationExecutionRecord( + clientId = execution.clientId, + clientSequence = execution.clientSequence, + phase = StoredPhase.REFRESH_REQUIRED, + currentGeneration = execution.currentGeneration, + attempt = execution.attempt + 1, + lastAttemptAt = occurredAt, + activeFailureId = null, + retiredAt = null, + ), + ) + } +} + +private data class RedrawSeed( + val seed: Int, + val first: Long, + val second: Long, +) + +private fun seedWithDescendingFirstTwoJitters(window: Long): RedrawSeed { + for (seed in 1..10_000) { + val random = Random(seed) + val first = random.nextLong(window + 1L) + val second = random.nextLong(window + 1L) + if (first > second) return RedrawSeed(seed, first, second) + } + error("No deterministic redraw seed found") +} + +private fun seedWithPositiveFirstJitter(window: Long): Int { + for (seed in 1..10_000) { + if (Random(seed).nextLong(window + 1L) > 0L) return seed + } + error("No deterministic positive-jitter seed found") +} + +private data class StraddlingSeeds( + val highSeed: Int, + val lowSeed: Int, +) + +private fun seedsStraddling( + window: Long, + threshold: Long, +): StraddlingSeeds { + var high: Int? = null + var low: Int? = null + for (seed in 1..10_000) { + val jitter = Random(seed).nextLong(window + 1L) + if (jitter > threshold && high == null) high = seed + if (jitter <= threshold && low == null) low = seed + if (high != null && low != null) return StraddlingSeeds(high, low) + } + error("No deterministic straddling seeds found") +} + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = 25.seconds, testBody = testBody) diff --git a/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationBookkeeperContractTest.kt b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationBookkeeperContractTest.kt new file mode 100644 index 000000000..94f05caf3 --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationBookkeeperContractTest.kt @@ -0,0 +1,24 @@ +package org.mobilenativefoundation.store6.mutations + +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.testing.BookkeeperContractKit + +/** + * Certifies the mutations-owned default [MutationBookkeeper] against the read-only + * store6-testing contract kit: the default may not reuse core's inaccessible internal + * implementation and must satisfy the kit. + * + * Every inherited kit member is a binding test contract and runs on every compiled target: + * `identityDerivation_equalCanonicalPair_sharesRecords`, + * `identityDerivation_differentCanonicalId_isolated`, `failureOnlyRecord_isNotDurablyStale`, + * `namespaceWatermark_thenLaterSuccess_clearsDurableStaleness`, + * `watermarkOnlyKey_reportsDurablyStale`, and `recordSuccess_clearsFailureCountAndTimestamp`. + * This subclass adds nothing and overrides nothing beyond the factory, so the kit alone owns the + * assertions. + */ +@OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) +class MutationBookkeeperContractTest : BookkeeperContractKit() { + override fun createBookkeeper(): Bookkeeper = MutationBookkeeper() +} diff --git a/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationConflictTest.kt b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationConflictTest.kt new file mode 100644 index 000000000..896ff57ed --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationConflictTest.kt @@ -0,0 +1,1604 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, + kotlinx.coroutines.ExperimentalCoroutinesApi::class, +) + +package org.mobilenativefoundation.store6.mutations + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.selects.select +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreException +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreMeta +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import org.mobilenativefoundation.store6.core.seam.KeyStatus +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import org.mobilenativefoundation.store6.core.seam.StoreResults +import org.mobilenativefoundation.store6.core.seam.StoreWriteHandle +import org.mobilenativefoundation.store6.core.seam.WallClock +import org.mobilenativefoundation.store6.mutations.storage.InMemoryMutationJournalStorage +import org.mobilenativefoundation.store6.mutations.storage.MutationAckRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationAttemptRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationClientRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationEffectDisposition +import org.mobilenativefoundation.store6.mutations.storage.MutationEffectRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionPhase as StoredPhase +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationFailureRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationIntentRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationJournalStorage +import org.mobilenativefoundation.store6.mutations.storage.MutationKeyAliasRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationKeyTombstoneRecord +import org.mobilenativefoundation.store6.testing.TestWallClock +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNotSame +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlin.test.fail +import kotlin.time.Duration.Companion.seconds + +class MutationConflictTest { + @Test + fun presentAbsentMatrix_survivesConflictRetryAndServerWins() = runTest { + for (basePresent in listOf(true, false)) { + for (retry in listOf(true, false)) { + assertMatrixCell( + basePresent = basePresent, + retry = retry, + restartAfterReceipt = basePresent && retry, + ) + } + } + } + + @Test + fun retryPersistsGenerationPlusOneAndNewIdempotencyKeyBeforePush() = runTest { + val delegate = InMemoryMutationJournalStorage() + val storage = FailPointJournalStorage(delegate) + val mutations = ConflictMutations() + val backend = retainingConflictBackend() + val key = MutationsTestKey("failed-g2-transaction") + val policy = retryMinePolicy() + val clock = TestWallClock(100L) + var activeStore: MutationStore? = + openConflictStore(storage, mutations, backend, policy, wallClock = clock) + try { + val initialStore = checkNotNull(activeStore) + backend.seed(key, "base") + initialStore.get(key, Freshness.MustBeFresh) + val mutationId = initialStore.mutate(key, mutations.set, "mine") + backend.pushBehavior = { _, value -> + if (backend.receivedPushes.last().generation == 1) { + throw conflict(ConflictTestMeta(10L, "c1")) + } + MutationPresentAck(value, "acked-g2", null) + } + + initialStore.drain(key) + clock.advanceBy(2.seconds) + val receipt = delegate.conflictState(mutationId) + assertEquals(StoredPhase.REFRESH_REQUIRED, receipt.execution.phase) + val generationOneKey = receipt.attempts.single().generationIdempotencyKey + + storage.armFailTransaction { before, after -> + before.phase == StoredPhase.REFRESH_REQUIRED && + after.phase == StoredPhase.READY && + after.currentGeneration == before.currentGeneration + 1 + } + assertFailsWith { initialStore.drain(key) } + + val rolledBack = delegate.conflictState(mutationId) + assertEquals(StoredPhase.REFRESH_REQUIRED, rolledBack.execution.phase) + assertEquals(1, rolledBack.execution.currentGeneration) + assertEquals(1, rolledBack.attempts.size) + assertEquals(1, backend.receivedPushes.size) + + initialStore.close() + activeStore = null + val reopened = openConflictStore(storage, mutations, backend, policy, wallClock = clock) + activeStore = reopened + reopened.drain(key) + val completed = delegate.conflictState(mutationId) + assertEquals(StoredPhase.RETIRED, completed.execution.phase) + assertEquals(listOf(1, 2), completed.attempts.map { it.generation }) + val generationTwo = completed.attempts.single { it.generation == 2 } + assertNotEquals(generationOneKey, generationTwo.generationIdempotencyKey) + assertEquals(generationTwo.generationIdempotencyKey, backend.receivedPushes.last().idempotencyKey) + assertEquals(2, backend.receivedPushes.size) + } finally { + activeStore?.close() + } + } + + @Test + fun freshnessBarrierRecapturesBaseThroughSameOrderedCaptureLoop() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = ConflictMutations() + val backend = retainingConflictBackend() + val trace = CaptureTrace() + val source = TracingSourceOfTruth(trace) + val bookkeeper = TracingBookkeeper(trace) + val clock = TestWallClock(100L) + var theirs: MutationPresence? = null + val policy = + ConflictPolicy( + merge = { _, _, captured -> + theirs = captured + MutationConflictResolution.ServerWins + }, + ) + val store = + openConflictStore( + storage, + mutations, + backend, + policy, + sourceOfTruth = source, + bookkeeper = bookkeeper, + wallClock = clock, + fetcher = { + trace.events += "barrier-missing" + trace.afterBarrier = true + FetcherResult.Deleted + }, + ) + val key = MutationsTestKey("missing-barrier") + try { + val mutationId = store.mutate(key, mutations.set, "mine") + backend.pushBehavior = { _, _ -> throw conflict(null, "metadata-free conflict") } + + store.drain(key) + clock.advanceBy(2.seconds) + store.drain(key) + + assertSame(MutationPresence.Absent, theirs) + assertEquals("barrier-missing", trace.events.first()) + val localReads = trace.events.indices.filter { trace.events[it] == "local" } + assertEquals(1, localReads.size) + val localRead = localReads.single() + assertTrue(trace.events.subList(1, localRead).any { it == "status" }) + assertTrue(trace.events.subList(localRead + 1, trace.events.size).any { it == "status" }) + assertEquals("status", trace.events.last()) + assertEquals(StoredPhase.RETIRED, storage.conflictState(mutationId).execution.phase) + } finally { + store.close() + } + } + + @Test + fun selectedMetadataAndExistenceFormCompletePrecondition() = runTest { + val selected = MutableMeta(700L, "selected") + val present = selectorCell(basePresent = true) { selected } + selected.writtenAtEpochMillis = 9_999L + selected.etag = "mutated-after-selection" + assertEquals(MutationPresenceState.PRESENT, present.attempt.basePresence) + assertContentEquals("base".encodeToByteArray(), present.attempt.baseBlob) + assertEquals(700L, present.attempt.preconditionWrittenAt) + assertEquals("selected", present.attempt.preconditionEtag) + assertEquals(700L, assertNotNull(present.push.baseMeta).writtenAtEpochMillis) + assertEquals("selected", present.push.baseMeta?.etag) + + val absent = selectorCell(basePresent = false) { null } + assertEquals(MutationPresenceState.ABSENT, absent.attempt.basePresence) + assertNull(absent.attempt.baseBlob) + assertFalse(absent.attempt.preconditionMetaPresent) + assertNull(absent.push.baseMeta) + assertSame(MutationPresence.Absent, absent.push.base) + + val storage = InMemoryMutationJournalStorage() + val mutations = ConflictMutations() + val backend = FakeBackend() + val store = + openConflictStore( + storage, + mutations, + backend, + ConflictPolicy(precondition = { error("selector exploded") }), + ) + val key = MutationsTestKey("selector-throw") + try { + val mutationId = store.mutate(key, mutations.set, "mine") + store.drain(key) + val parked = storage.conflictState(mutationId) + assertEquals(StoredPhase.PARKED, parked.execution.phase) + assertEquals(0, backend.receivedPushes.size) + assertEquals(MutationFailureKind.CONFLICT, parked.activeFailure?.kind) + assertEquals("selector-failed", parked.activeFailure?.detail) + } finally { + store.close() + } + } + + @Test + fun preconditionSelectorRunsOncePerGenerationAndNeverOnTransportRetry() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = ConflictMutations() + val backend = retainingConflictBackend() + val generations = mutableListOf() + val selectedByGeneration = mutableMapOf() + val clock = TestWallClock(100L) + val policy = + ConflictPolicy( + precondition = { candidate -> + generations += candidate.generation + MutableMeta( + writtenAtEpochMillis = 1_000L + candidate.generation, + etag = "selector-g${candidate.generation}", + ).also { selectedByGeneration[candidate.generation] = it } + }, + merge = { _, mine, _ -> MutationConflictResolution.Retry(mine) }, + ) + val store = openConflictStore(storage, mutations, backend, policy, wallClock = clock) + val key = MutationsTestKey("selector-count") + backend.seed(key, "base") + try { + store.get(key, Freshness.MustBeFresh) + val mutationId = store.mutate(key, mutations.set, "mine") + val embeddedConflict = conflictException(ConflictTestMeta(1L, "embedded"), "embedded") + val ordinaryTransport = + StoreResults.exception( + StoreResults.fetchError("ordinary transport", embeddedConflict), + embeddedConflict, + ) + backend.pushBehavior = { _, value -> + when (backend.receivedPushes.size) { + 1 -> throw ordinaryTransport + 2 -> throw conflict(ConflictTestMeta(2L, "actual")) + else -> { + selectedByGeneration.getValue(2).apply { + writtenAtEpochMillis = 9_002L + etag = "mutated-g2" + } + MutationPresentAck(value, "acked", null) + } + } + } + + store.drain(key) + clock.advanceBy(2.seconds) + assertEquals(listOf(1), generations) + val transport = storage.conflictState(mutationId) + assertEquals(StoredPhase.READY, transport.execution.phase) + assertEquals(MutationFailureKind.TRANSPORT, transport.failures.single().kind) + assertEquals("push-failed", transport.failures.single().detail) + val generationOne = transport.attempts.single() + assertEquals(1_001L, generationOne.preconditionWrittenAt) + assertEquals("selector-g1", generationOne.preconditionEtag) + selectedByGeneration.getValue(1).apply { + writtenAtEpochMillis = 9_001L + etag = "mutated-g1" + } + + store.drain(key) + clock.advanceBy(3.seconds) + assertEquals(listOf(1), generations) + val conflicted = storage.conflictState(mutationId) + assertEquals(StoredPhase.REFRESH_REQUIRED, conflicted.execution.phase) + assertEquals(1_001L, conflicted.attempts.single().preconditionWrittenAt) + assertEquals("selector-g1", conflicted.attempts.single().preconditionEtag) + assertEquals(1_001L, assertNotNull(backend.receivedPushes[1].baseMeta).writtenAtEpochMillis) + assertEquals("selector-g1", backend.receivedPushes[1].baseMeta?.etag) + + store.drain(key) + assertEquals(listOf(1, 2), generations) + val completed = storage.conflictState(mutationId) + assertEquals(StoredPhase.RETIRED, completed.execution.phase) + val frozenOne = completed.attempts.single { it.generation == 1 } + val frozenTwo = completed.attempts.single { it.generation == 2 } + assertNotSame(selectedByGeneration.getValue(1), selectedByGeneration.getValue(2)) + assertEquals(1_001L, frozenOne.preconditionWrittenAt) + assertEquals("selector-g1", frozenOne.preconditionEtag) + assertEquals(1_002L, frozenTwo.preconditionWrittenAt) + assertEquals("selector-g2", frozenTwo.preconditionEtag) + val generationTwoPush = backend.receivedPushes.single { it.generation == 2 } + assertEquals(1_002L, assertNotNull(generationTwoPush.baseMeta).writtenAtEpochMillis) + assertEquals("selector-g2", generationTwoPush.baseMeta?.etag) + } finally { + store.close() + } + } + + @Test + fun mustBeFreshIsBarrierThenTheirsIsRecaptured() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = ConflictMutations() + val backend = retainingConflictBackend() + val key = MutationsTestKey("discard-barrier-value") + val trace = mutableListOf() + val clock = TestWallClock(100L) + val barrierEntered = CompletableDeferred() + val releaseBarrier = CompletableDeferred() + val recaptureEntered = CompletableDeferred() + val recapturedY = CompletableDeferred() + var recaptureArmed = false + var mergeTheirs: MutationPresence? = null + val policy = + ConflictPolicy( + merge = { _, _, theirs -> + mergeTheirs = theirs + trace += "merge:${theirs.valueOrAbsent()}" + MutationConflictResolution.ServerWins + }, + ) + val engine = + openConflictEngine( + storage, + mutations, + backend, + policy, + wallClock = clock, + baseReader = { + if (!recaptureArmed) { + "base" + } else { + recaptureEntered.complete(Unit) + recapturedY.await() + } + }, + freshnessBarrier = { + trace += "barrier:X" + trace += "commit:X" + barrierEntered.complete(Unit) + releaseBarrier.await() + recaptureArmed = true + }, + ) + try { + val mutationId = engine.mutate(key, mutations.set, "mine") + backend.pushBehavior = { _, _ -> throw conflict(ConflictTestMeta(2L, "conflict")) } + engine.drain(key) + assertEquals(StoredPhase.REFRESH_REQUIRED, storage.conflictState(mutationId).execution.phase) + clock.advanceBy(2.seconds) + + val resolving = backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { engine.drain(key) } + select { + barrierEntered.onAwait { } + resolving.onJoin { + fail("Conflict resolution completed before the MustBeFresh commit gate.") + } + } + releaseBarrier.complete(Unit) + select { + recaptureEntered.onAwait { } + resolving.onJoin { + fail("Conflict resolution completed before post-close raw Y was mapped.") + } + } + trace += "external:Y" + trace += "raw:Y" + recapturedY.complete("recaptured-Y") + resolving.await() + + assertEquals("recaptured-Y", assertIs>(mergeTheirs).value) + assertEquals( + listOf( + "barrier:X", + "commit:X", + "external:Y", + "raw:Y", + "merge:recaptured-Y", + ), + trace, + ) + assertEquals(StoredPhase.RETIRED, storage.conflictState(mutationId).execution.phase) + assertEquals(1, backend.receivedPushes.size) + } finally { + releaseBarrier.complete(Unit) + recapturedY.complete("recaptured-Y") + } + } + + @Test + fun retryPersistsGenerationPlusOneBeforeTransmission() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = ConflictMutations() + val backend = FakeBackend() + val clock = TestWallClock(100L) + val store = + openConflictStore(storage, mutations, backend, retryMinePolicy(), wallClock = clock) + val key = MutationsTestKey("persist-before-send") + backend.seed(key, "base") + var atTransmission: ConflictState? = null + try { + store.get(key, Freshness.MustBeFresh) + val mutationId = store.mutate(key, mutations.set, "mine") + backend.pushBehavior = { _, value -> + val push = backend.receivedPushes.last() + if (push.generation == 1) throw conflict(ConflictTestMeta(3L, "c1")) + atTransmission = storage.conflictState(mutationId) + MutationPresentAck(value, "acked", null) + } + + store.drain(key) + clock.advanceBy(2.seconds) + store.drain(key) + + val observed = assertNotNull(atTransmission) + val generationOne = observed.attempts.single { it.generation == 1 } + val generationTwo = observed.attempts.single { it.generation == 2 } + assertEquals(StoredPhase.INFLIGHT, observed.execution.phase) + assertEquals(2, observed.execution.currentGeneration) + assertNotEquals(generationOne.generationIdempotencyKey, generationTwo.generationIdempotencyKey) + val sent = backend.receivedPushes.last() + assertEquals(generationTwo.generationIdempotencyKey, sent.idempotencyKey) + assertEquals(generationTwo.basePresence, sent.base.toState()) + assertEquals(generationTwo.minePresence, sent.mine.toState()) + assertContentEquals(generationTwo.baseBlob, sent.base.encode(ConflictStringCodec)) + assertContentEquals(generationTwo.mineBlob, sent.mine.encode(ConflictStringCodec)) + } finally { + store.close() + } + } + + @Test + fun mergeThrow_parksAsNormalizedConflictWithoutTransport() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = ConflictMutations() + val backend = FakeBackend() + val clock = TestWallClock(100L) + val store = + openConflictStore( + storage, + mutations, + backend, + ConflictPolicy(merge = { _, _, _ -> error("merge exploded") }), + wallClock = clock, + ) + val key = MutationsTestKey("merge-throw") + try { + val mutationId = store.mutate(key, mutations.set, "mine") + backend.pushBehavior = { _, _ -> throw conflict(ConflictTestMeta(4L, "c1")) } + store.drain(key) + clock.advanceBy(2.seconds) + store.drain(key) + + val state = storage.conflictState(mutationId) + assertEquals(StoredPhase.PARKED, state.execution.phase) + assertEquals(1, state.attempts.size) + assertEquals(1, backend.receivedPushes.size) + assertEquals(MutationFailureKind.CONFLICT, state.activeFailure?.kind) + assertEquals("merge-failed", state.activeFailure?.detail) + } finally { + store.close() + } + } + + @Test + fun mergeCancellation_rethrowsAndPreservesRefreshRequiredGeneration() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = ConflictMutations() + val backend = retainingConflictBackend() + val cancellation = CancellationException("cancel exact merge") + var cancelMerge = true + val clock = TestWallClock(100L) + val policy = + ConflictPolicy( + merge = { _, mine, _ -> + if (cancelMerge) throw cancellation + MutationConflictResolution.Retry(mine) + }, + ) + val store = openConflictStore(storage, mutations, backend, policy, wallClock = clock) + val key = MutationsTestKey("merge-cancellation") + try { + val mutationId = store.mutate(key, mutations.set, "mine") + backend.pushBehavior = { _, value -> + if (backend.receivedPushes.last().generation == 1) { + throw conflict(ConflictTestMeta(5L, "c1")) + } + MutationPresentAck(value, "acked", null) + } + store.drain(key) + clock.advanceBy(2.seconds) + val before = storage.conflictState(mutationId).fingerprint() + + val thrown = assertFailsWith { store.drain(key) } + assertSame(cancellation, thrown) + assertEquals(before, storage.conflictState(mutationId).fingerprint()) + assertEquals(1, backend.receivedPushes.size) + + cancelMerge = false + store.drain(key) + assertEquals(StoredPhase.RETIRED, storage.conflictState(mutationId).execution.phase) + assertEquals(2, backend.receivedPushes.size) + } finally { + store.close() + } + } + + @Test + fun globalDrainContinuesAfterMergeFailure() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = ConflictMutations() + val backend = FakeBackend() + val clock = TestWallClock(10_000L) + val policy = + ConflictPolicy( + merge = { _, mine, _ -> + val value = assertIs>(mine).value + if (value == "mine-A") error("A merge failed") + MutationConflictResolution.Retry(mine) + }, + ) + val resolver = + MutationKeyResolver { identity -> + MutationsTestKey(identity.canonicalId, StoreNamespace(identity.namespace)) + } + val store = + openConflictStore( + storage, + mutations, + backend, + policy, + wallClock = clock, + keyResolver = resolver, + ) + val keyA = MutationsTestKey("global-A") + val keyB = MutationsTestKey("global-B", StoreNamespace("global-healthy")) + try { + val mutationA = store.mutate(keyA, mutations.set, "mine-A") + val mutationB = store.mutate(keyB, mutations.set, "mine-B") + backend.pushBehavior = { _, value -> + if (value == "mine-A") throw conflict(ConflictTestMeta(6L, "a")) + MutationPresentAck(value, "acked-b", null) + } + store.drain(keyA) + clock.advanceBy(2.seconds) + + store.drain() + + val stateA = storage.conflictState(mutationA) + val stateB = storage.conflictState(mutationB) + assertEquals(StoredPhase.PARKED, stateA.execution.phase) + assertEquals("merge-failed", stateA.activeFailure?.detail) + assertEquals(StoredPhase.RETIRED, stateB.execution.phase) + assertTrue(backend.receivedPushes.any { it.key.canonicalId() == keyB.canonicalId() }) + } finally { + store.close() + } + } + + @Test + fun serverWinsSkipsEffectsAndRetiresWithoutAnotherPush() = runTest { + val delegate = InMemoryMutationJournalStorage() + val storage = FailPointJournalStorage(delegate) + val mutations = ConflictMutations(withEffect = true) + val backend = retainingConflictBackend() + val handle = CountingConflictHandle() + val clock = TestWallClock(100L) + val engine = + openConflictEngine( + storage, + mutations, + backend, + policy = null, + handle = handle, + wallClock = clock, + ) + val key = MutationsTestKey("server-wins-default") + val observedRevisions = mutableListOf() + val collector = + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + engine.changes.collect { changed -> + if (changed.canonicalId() == key.canonicalId()) { + observedRevisions += changed.canonicalId() + } + } + } + try { + val mutationId = engine.mutate(key, mutations.set, "mine") + backend.pushBehavior = { _, _ -> throw conflict(ConflictTestMeta(7L, "server")) } + engine.drain(key) + clock.advanceBy(2.seconds) + testScheduler.runCurrent() + val revisionCountBeforeTerminal = observedRevisions.size + assertEquals(StoredPhase.REFRESH_REQUIRED, delegate.conflictState(mutationId).execution.phase) + assertEquals("mine", engine.overlay.apply(key, "base")) + + storage.armFailTransaction { before, after -> + before.phase == StoredPhase.REFRESH_REQUIRED && after.phase == StoredPhase.RETIRED + } + assertFailsWith { engine.drain(key) } + testScheduler.runCurrent() + + val rolledBack = delegate.conflictState(mutationId) + assertEquals(StoredPhase.REFRESH_REQUIRED, rolledBack.execution.phase) + assertTrue(rolledBack.effects.all { it.disposition == MutationEffectDisposition.PENDING }) + assertEquals(0L, assertNotNull(rolledBack.client).retiredThroughSequence) + assertEquals(revisionCountBeforeTerminal, observedRevisions.size) + assertEquals("mine", engine.overlay.apply(key, "base")) + assertEquals(1, backend.receivedPushes.size) + + engine.drain(key) + + val state = delegate.conflictState(mutationId) + assertEquals(StoredPhase.RETIRED, state.execution.phase) + assertTrue(state.effects.isNotEmpty()) + assertTrue(state.effects.all { it.disposition == MutationEffectDisposition.SKIPPED }) + assertEquals(1L, assertNotNull(state.client).retiredThroughSequence) + assertEquals(1, backend.receivedPushes.size) + assertEquals(emptyList(), state.acks) + assertEquals(emptyList(), state.aliases) + assertEquals(emptyList(), state.tombstones) + assertEquals(0, handle.markStaleCalls) + assertEquals(0, handle.applyCalls) + assertEquals(0, handle.confirmFreshCalls) + assertEquals("base", engine.overlay.apply(key, "base")) + testScheduler.runCurrent() + assertTrue(observedRevisions.size > revisionCountBeforeTerminal) + } finally { + collector.cancelAndJoin() + } + } + + @Test + fun unchangedRepeatedConflictEventuallyParks() = runTest { + val unchangedMetas = + listOf( + ConflictTestMeta(8L, "A"), + ConflictTestMeta(8L, "A"), + ConflictTestMeta(8L, "A"), + ) + val unchangedRun = runConflictSequence(unchangedMetas) + val unchanged = unchangedRun.state + assertEquals(StoredPhase.PARKED, unchanged.execution.phase) + assertEquals(3, unchanged.execution.currentGeneration) + assertEquals(1, unchanged.execution.attempt) + assertEquals(3, unchanged.attempts.size) + assertConflictReceipts(unchanged, unchangedMetas, unchangedRun.receiptTimes) + assertEquals(1, unchanged.failures.size) + assertEquals(MutationFailureKind.CONFLICT, unchanged.activeFailure?.kind) + assertEquals("conflict-unchanged-bound", unchanged.activeFailure?.detail) + + val resetMetas = + listOf( + ConflictTestMeta(8L, "A"), + ConflictTestMeta(8L, "A"), + ConflictTestMeta(8L, "B"), + ) + val resetRun = runConflictSequence(resetMetas) + val reset = resetRun.state + assertEquals(StoredPhase.REFRESH_REQUIRED, reset.execution.phase) + assertEquals(3, reset.execution.currentGeneration) + assertEquals(1, reset.execution.attempt) + assertEquals(3, reset.attempts.size) + assertConflictReceipts(reset, resetMetas, resetRun.receiptTimes) + assertEquals(emptyList(), reset.failures) + // The bound is derived solely from the three immutable attempt rows; no counter record is + // addressable through the frozen nine-record journal interface. + } + + @Test + fun refreshFailurePreservesCurrentGenerationAndBackoffFacts() = runTest { + val codecRestart = + runRefreshRequiredRestartCase( + name = "refresh-codec", + valueCodec = ThrowingVersionOneConflictCodec, + drain = { restarted, key -> restarted.drain(key) }, + ) + val identityRestart = + runRefreshRequiredRestartCase( + name = "refresh-identity", + keyResolver = MutationKeyResolver { null }, + drain = { restarted, _ -> restarted.drain() }, + ) + assertRefreshRequiredRestartCases(codecRestart, identityRestart) + + val storage = InMemoryMutationJournalStorage() + val mutations = ConflictMutations() + val backend = retainingConflictBackend() + var refreshFailure: Throwable? = null + val clock = TestWallClock(100L) + val store = + openConflictStore( + storage, + mutations, + backend, + retryMinePolicy(), + wallClock = clock, + fetcher = { key -> + refreshFailure?.let { throw it } + backend.loadResult(key) + }, + ) + val key = MutationsTestKey("refresh-failure") + backend.seed(key, "base") + try { + store.get(key, Freshness.MustBeFresh) + val mutationId = store.mutate(key, mutations.set, "mine") + backend.pushBehavior = { _, value -> + if (backend.receivedPushes.last().generation == 1) { + throw conflict(ConflictTestMeta(10L, "c1")) + } + MutationPresentAck(value, "acked", null) + } + store.drain(key) + clock.advanceBy(2.seconds) + val before = storage.conflictState(mutationId).fullFingerprint(backend.receivedPushes.size) + + refreshFailure = IllegalStateException("refresh unavailable") + assertFailsWith { store.drain(key) } + assertEquals(before, storage.conflictState(mutationId).fullFingerprint(backend.receivedPushes.size)) + + refreshFailure = null + store.drain(key) + assertEquals(StoredPhase.RETIRED, storage.conflictState(mutationId).execution.phase) + } finally { + store.close() + } + } + + @Test + fun serverWinsCancellationAfterCommit_stillPublishesOverlayRevision() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = ConflictMutations() + val backend = FakeBackend() + val policy = ConflictPolicy(merge = { _, _, _ -> MutationConflictResolution.ServerWins }) + val clock = TestWallClock(100L) + val engine = openConflictEngine(storage, mutations, backend, policy, wallClock = clock) + val key = MutationsTestKey("cancelled-server-wins") + val blocker = MutationsTestKey("server-wins-signal-blocker") + val filler = MutationsTestKey("server-wins-signal-filler") + val mutationId = engine.mutate(key, mutations.set, "mine") + backend.pushBehavior = { _, _ -> throw conflict(ConflictTestMeta(11L, "server")) } + engine.drain(key) + clock.advanceBy(2.seconds) + assertEquals(StoredPhase.REFRESH_REQUIRED, storage.conflictState(mutationId).execution.phase) + assertEquals("mine", engine.overlay.apply(key, "base")) + + val initialKeyObserved = CompletableDeferred() + val blockerEntered = CompletableDeferred() + val releaseBlocker = CompletableDeferred() + val retiredObserved = CompletableDeferred() + val collector = + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + engine.changes.collect { changed -> + when (changed.canonicalId()) { + key.canonicalId() -> { + if (!initialKeyObserved.complete(Unit)) retiredObserved.complete(Unit) + } + blocker.canonicalId() -> { + blockerEntered.complete(Unit) + releaseBlocker.await() + } + } + } + } + try { + testScheduler.runCurrent() + initialKeyObserved.await() + engine.mutate(blocker, mutations.set, "blocker") + testScheduler.runCurrent() + blockerEntered.await() + // The blocked collector has consumed [blocker]. This second revision fills the sole + // replay slot, so the following ServerWins key revision must suspend at publication. + engine.mutate(filler, mutations.set, "filler") + val draining = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.drain(key) + } + assertFalse(draining.isCompleted) + assertEquals(StoredPhase.RETIRED, storage.conflictState(mutationId).execution.phase) + assertEquals("base", engine.overlay.apply(key, "base")) + assertFalse(retiredObserved.isCompleted) + + draining.cancel() + assertFalse(retiredObserved.isCompleted) + releaseBlocker.complete(Unit) + testScheduler.runCurrent() + draining.join() + assertTrue(draining.isCancelled) + assertTrue(retiredObserved.isCompleted) + assertEquals("base", engine.overlay.apply(key, "base")) + } finally { + releaseBlocker.complete(Unit) + collector.cancelAndJoin() + } + } +} + +private const val CONFLICT_CLIENT_ID: String = "client-0" + +private class ConflictMutations( + withEffect: Boolean = false, +) { + lateinit var set: MutatorRef + + val registry: MutatorRegistry = + mutatorRegistry { + set = + upsert( + id = "conflict-set", + version = 1, + codec = FixtureStringArgsCodec, + stales = { key, _ -> + if (withEffect) { + StaleSet(keys = setOf(key), namespaces = emptySet()) + } else { + StaleSet(keys = emptySet(), namespaces = emptySet()) + } + }, + ) { _, value -> MutationPresence.Present(value) } + } +} + +private object ConflictStringCodec : MutationCodec { + override fun encode(value: String): ByteArray = value.encodeToByteArray() + + override fun decode(version: Int, bytes: ByteArray): String { + require(version == 1) + return bytes.decodeToString() + } +} + +private object ThrowingVersionOneConflictCodec : MutationCodec { + override fun encode(value: String): ByteArray = value.encodeToByteArray() + + override fun decode(version: Int, bytes: ByteArray): String { + require(version == 1) + error("version-1 value decode failed") + } +} + +private data class ConflictPolicy( + val precondition: ((MutationPreconditionCandidate) -> StoreMeta?)? = null, + val merge: + ((MutationPresence, MutationPresence, MutationPresence) -> + MutationConflictResolution)? = null, +) + +private fun retryMinePolicy(): ConflictPolicy = + ConflictPolicy(merge = { _, mine, _ -> MutationConflictResolution.Retry(mine) }) + +private fun retainingConflictBackend(): FakeBackend = + FakeBackend().apply { + retireBehavior = { + MutationRetirementAck(confirmedThroughSequence = 0L) + } + } + +private fun openConflictStore( + storage: MutationJournalStorage, + mutations: ConflictMutations, + backend: FakeBackend, + policy: ConflictPolicy?, + sourceOfTruth: SourceOfTruth = MutationSourceOfTruth(), + bookkeeper: Bookkeeper = MutationBookkeeper(), + wallClock: WallClock = TestWallClock(100L), + fetcher: suspend (MutationsTestKey) -> FetcherResult = backend::loadResult, + keyResolver: MutationKeyResolver = MutationsTestKeyResolver, + valueCodec: MutationCodec = ConflictStringCodec, +): MutationStore = + mutationStore( + registry = mutations.registry, + server = backend, + keyResolver = keyResolver, + valueCodecVersion = 1, + valueCodec = valueCodec, + ) { + fetcherOfResult(fetcher) + persistence(sourceOfTruth) + bookkeeper(bookkeeper) + wallClock(wallClock) + journalStorage(storage) + if (policy != null) { + conflicts { + policy.precondition?.let { precondition(it) } + policy.merge?.let { merge(it) } + } + } + } + +private fun openConflictEngine( + storage: MutationJournalStorage, + mutations: ConflictMutations, + backend: FakeBackend, + policy: ConflictPolicy?, + handle: StoreWriteHandle = ConflictNoopHandle, + baseReader: suspend (MutationsTestKey) -> String? = { "base" }, + freshnessBarrier: suspend (MutationsTestKey) -> Unit = {}, + wallClock: WallClock = TestWallClock(100L), +): MutationEngine { + val journal = + StorageBackedMutationJournal( + storage = storage, + registrations = mutations.registry.registrations, + clientId = CONFLICT_CLIENT_ID, + hydrateOnFirstUse = true, + ) + return MutationEngine( + registry = mutations.registry, + server = backend, + journal = journal, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = ConflictStringCodec, + conflicts = policy?.let { MutationConflictRegistration(it.precondition, it.merge) }, + baseReader = baseReader, + freshnessBarrier = freshnessBarrier, + wallClock = wallClock, + clientId = CONFLICT_CLIENT_ID, + ).also { it.bind(handle) } +} + +private object ConflictNoopHandle : StoreWriteHandle { + override suspend fun apply(key: MutationsTestKey, value: String) = Unit + + override suspend fun markStale(key: MutationsTestKey) = Unit + + override suspend fun confirmFresh(key: MutationsTestKey, etag: String?) = Unit +} + +private class CountingConflictHandle : StoreWriteHandle { + var applyCalls: Int = 0 + private set + var markStaleCalls: Int = 0 + private set + var confirmFreshCalls: Int = 0 + private set + + override suspend fun apply(key: MutationsTestKey, value: String) { + applyCalls += 1 + } + + override suspend fun markStale(key: MutationsTestKey) { + markStaleCalls += 1 + } + + override suspend fun confirmFresh(key: MutationsTestKey, etag: String?) { + confirmFreshCalls += 1 + } +} + +private suspend fun TestScope.assertMatrixCell( + basePresent: Boolean, + retry: Boolean, + restartAfterReceipt: Boolean, +) { + val storage = InMemoryMutationJournalStorage() + val mutations = ConflictMutations() + val backend = retainingConflictBackend() + val key = MutationsTestKey("matrix-${if (basePresent) "present" else "absent"}-${if (retry) "retry" else "server"}") + val sourceOfTruth = MutationSourceOfTruth() + val bookkeeper = MutationBookkeeper() + val clock = TestWallClock(100L) + var mergeBase: PresenceSnapshot? = null + var mergeMine: PresenceSnapshot? = null + val policy = + ConflictPolicy( + merge = { base, mine, _ -> + mergeBase = base.snapshot() + mergeMine = mine.snapshot() + if (retry) { + MutationConflictResolution.Retry(MutationPresence.Present("merged")) + } else { + MutationConflictResolution.ServerWins + } + }, + ) + var activeStore: MutationStore? = + openConflictStore( + storage, + mutations, + backend, + policy, + sourceOfTruth = sourceOfTruth, + bookkeeper = bookkeeper, + wallClock = clock, + ) + try { + var currentStore = checkNotNull(activeStore) + if (basePresent) { + backend.seed(key, "base") + currentStore.get(key, Freshness.MustBeFresh) + } + val mutationId = currentStore.mutate(key, mutations.set, "mine") + backend.pushBehavior = { _, value -> + if (backend.receivedPushes.last().generation == 1) { + throw conflict(ConflictTestMeta(20L, "matrix")) + } + MutationPresentAck(value, "acked", null) + } + currentStore.drain(key) + clock.advanceBy(2.seconds) + val received = storage.conflictState(mutationId) + assertEquals(StoredPhase.REFRESH_REQUIRED, received.execution.phase) + val durablePreImageBase = received.attempts.single().baseSnapshot() + val durablePreImageMine = received.attempts.single().mineSnapshot() + if (restartAfterReceipt) { + currentStore.close() + activeStore = null + sourceOfTruth.write(key, "perturbed-local") + bookkeeper.recordSuccess(key, ConflictTestMeta(99L, "perturbed-local")) + backend.seed(key, "perturbed-server") + currentStore = + openConflictStore( + storage, + mutations, + backend, + policy, + sourceOfTruth = sourceOfTruth, + bookkeeper = bookkeeper, + wallClock = clock, + ) + activeStore = currentStore + } + currentStore.drain(key) + val terminal = storage.conflictState(mutationId) + assertEquals(StoredPhase.RETIRED, terminal.execution.phase) + assertEquals(if (retry) 2 else 1, terminal.attempts.size) + assertEquals(if (retry) 2 else 1, backend.receivedPushes.size) + assertEquals( + if (basePresent) MutationPresenceState.PRESENT else MutationPresenceState.ABSENT, + terminal.attempts.first().basePresence, + ) + if (restartAfterReceipt) { + assertEquals(durablePreImageBase, mergeBase) + assertEquals(durablePreImageMine, mergeMine) + assertEquals(PresenceSnapshot(MutationPresenceState.PRESENT, "base"), mergeBase) + assertEquals(PresenceSnapshot(MutationPresenceState.PRESENT, "mine"), mergeMine) + } + } finally { + activeStore?.close() + } +} + +private data class PresenceSnapshot( + val state: MutationPresenceState, + val value: String?, +) + +private fun MutationPresence.snapshot(): PresenceSnapshot = + when (this) { + is MutationPresence.Present -> PresenceSnapshot(MutationPresenceState.PRESENT, value) + MutationPresence.Absent -> PresenceSnapshot(MutationPresenceState.ABSENT, null) + } + +private fun MutationAttemptRecord.baseSnapshot(): PresenceSnapshot = + PresenceSnapshot(basePresence, baseBlob?.decodeToString()) + +private fun MutationAttemptRecord.mineSnapshot(): PresenceSnapshot = + PresenceSnapshot(minePresence, mineBlob?.decodeToString()) + +private data class SelectorCell( + val attempt: MutationAttemptRecord, + val push: MutationPush, +) + +private suspend fun selectorCell( + basePresent: Boolean, + selector: (MutationPreconditionCandidate) -> StoreMeta?, +): SelectorCell { + val storage = InMemoryMutationJournalStorage() + val mutations = ConflictMutations() + val backend = retainingConflictBackend() + val key = MutationsTestKey("selector-${if (basePresent) "present" else "absent"}") + val store = openConflictStore(storage, mutations, backend, ConflictPolicy(precondition = selector)) + try { + if (basePresent) { + backend.seed(key, "base") + store.get(key, Freshness.MustBeFresh) + } + val mutationId = store.mutate(key, mutations.set, "mine") + store.drain(key) + return SelectorCell( + attempt = storage.conflictState(mutationId).attempts.single(), + push = backend.receivedPushes.single(), + ) + } finally { + store.close() + } +} + +private data class ConflictSequenceResult( + val state: ConflictState, + val receiptTimes: List, +) + +private suspend fun runConflictSequence(metas: List): ConflictSequenceResult { + val storage = InMemoryMutationJournalStorage() + val mutations = ConflictMutations() + val backend = FakeBackend() + val policy = retryMinePolicy() + val clock = TestWallClock(100L) + val receiptTimes = mutableListOf() + var store = openConflictStore(storage, mutations, backend, policy, wallClock = clock) + val key = MutationsTestKey("conflict-sequence-${metas.joinToString { it?.etag.orEmpty() }}") + return try { + val mutationId = store.mutate(key, mutations.set, "mine") + backend.pushBehavior = { _, _ -> + val meta = metas[backend.receivedPushes.size - 1] + throw conflict(meta, "sequence conflict") + } + repeat(metas.size) { index -> + store.drain(key) + receiptTimes += clock.nowEpochMillis() + if (index < metas.lastIndex) { + clock.advanceBy(2.seconds) + store.close() + store = openConflictStore(storage, mutations, backend, policy, wallClock = clock) + } + } + ConflictSequenceResult( + state = storage.conflictState(mutationId), + receiptTimes = receiptTimes, + ) + } finally { + store.close() + } +} + +private fun assertConflictReceipts( + state: ConflictState, + expected: List, + expectedReceiptTimes: List, +) { + assertEquals(expected.size, state.attempts.size) + assertEquals(expected.size, expectedReceiptTimes.size) + state.attempts.sortedBy { it.generation }.zip(expected).forEachIndexed { index, (attempt, meta) -> + assertEquals(meta != null, attempt.conflictMetaPresent) + assertEquals(meta?.writtenAtEpochMillis, attempt.conflictWrittenAt) + assertEquals(meta?.etag, attempt.conflictEtag) + assertEquals(expectedReceiptTimes[index], attempt.conflictReceivedAt) + } +} + +private fun conflict( + meta: StoreMeta?, + message: String = "conflict", +): StoreException = conflictException(meta, message) + +private fun conflictException(meta: StoreMeta?, message: String): StoreException = + StoreResults.exception( + StoreResults.conflict(meta, message), + IllegalStateException("backend conflict cause"), + ) + +private class ConflictTestMeta( + override val writtenAtEpochMillis: Long, + override val etag: String?, +) : StoreMeta + +private class MutableMeta( + override var writtenAtEpochMillis: Long, + override var etag: String?, +) : StoreMeta + +private data class ConflictState( + val client: MutationClientRecord?, + val intent: MutationIntentRecord, + val execution: MutationExecutionRecord, + val attempts: List, + val failures: List, + val activeFailure: MutationFailureRecord?, + val effects: List, + val acks: List, + val aliases: List, + val tombstones: List, +) + +private suspend fun MutationJournalStorage.conflictState(mutationId: String): ConflictState = + transaction { transaction -> + val intent = transaction.intents(CONFLICT_CLIENT_ID).single { it.mutationId == mutationId } + val execution = + transaction.executions(CONFLICT_CLIENT_ID).single { + it.clientSequence == intent.clientSequence + } + val failures = + transaction.failures(CONFLICT_CLIENT_ID).filter { + it.clientSequence == intent.clientSequence + } + ConflictState( + client = transaction.client(CONFLICT_CLIENT_ID), + intent = intent, + execution = execution, + attempts = + transaction.attempts(CONFLICT_CLIENT_ID).filter { + it.clientSequence == intent.clientSequence + }, + failures = failures, + activeFailure = execution.activeFailureId?.let { id -> failures.single { it.failureId == id } }, + effects = + transaction.effects(CONFLICT_CLIENT_ID).filter { + it.clientSequence == intent.clientSequence + }, + acks = + transaction.acks(CONFLICT_CLIENT_ID).filter { + it.clientSequence == intent.clientSequence + }, + aliases = transaction.aliases(), + tombstones = transaction.tombstones(), + ) + } + +private data class StateFingerprint( + val phase: StoredPhase, + val generation: Int, + val attempt: Int, + val lastAttemptAt: Long?, + val activeFailureId: Long?, + val attempts: List, + val failures: List>, +) + +private data class AttemptFingerprint( + val generation: Int, + val base: List?, + val mine: List?, + val key: String, + val conflictMetaPresent: Boolean?, + val conflictWrittenAt: Long?, + val conflictEtag: String?, + val conflictReceivedAt: Long?, +) + +private fun ConflictState.fingerprint(): StateFingerprint = + StateFingerprint( + execution.phase, + execution.currentGeneration, + execution.attempt, + execution.lastAttemptAt, + execution.activeFailureId, + attempts.map { + AttemptFingerprint( + it.generation, + it.baseBlob?.toList(), + it.mineBlob?.toList(), + it.generationIdempotencyKey, + it.conflictMetaPresent, + it.conflictWrittenAt, + it.conflictEtag, + it.conflictReceivedAt, + ) + }, + failures.map { it.kind to it.detail }, + ) + +private data class FullFingerprint( + val state: StateFingerprint, + val pushCount: Int, + val ackCount: Int, + val effectFacts: List>, +) + +private fun ConflictState.fullFingerprint(pushCount: Int): FullFingerprint = + FullFingerprint( + fingerprint(), + pushCount, + acks.size, + effects.map { it.disposition to it.completedAt }, + ) + +private data class DeadLetterFingerprint( + val mutationId: String, + val generation: Int, + val attempts: Int, + val kind: MutationFailureKind, + val detail: String, +) + +private data class RefreshRequiredRestartCase( + val name: String, + val mutationId: String, + val generation: Int, + val attempt: Int, + val lastAttemptAt: Long?, + val immutableAttempt: AttemptFingerprint, + val afterHydration: ConflictState, + val hydrationDeadLetters: List, + val hydrationPushes: Int, + val afterPass: ConflictState, + val passDeadLetters: List, + val replayPushes: Int, + val afterSecondReopen: ConflictState, + val secondReopenDeadLetters: List, + val secondReopenPushes: Int, +) + +private suspend fun runRefreshRequiredRestartCase( + name: String, + valueCodec: MutationCodec = ConflictStringCodec, + keyResolver: MutationKeyResolver = MutationsTestKeyResolver, + drain: suspend (MutationStore, MutationsTestKey) -> Unit, +): RefreshRequiredRestartCase { + val storage = InMemoryMutationJournalStorage() + val mutations = ConflictMutations() + val initialBackend = FakeBackend() + val key = MutationsTestKey(name) + val clock = TestWallClock(100L) + val policy = retryMinePolicy() + val initial = openConflictStore(storage, mutations, initialBackend, policy, wallClock = clock) + val mutationId: String + try { + mutationId = initial.mutate(key, mutations.set, "mine-$name") + initialBackend.pushBehavior = { _, _ -> throw conflict(ConflictTestMeta(12L, name)) } + initial.drain(key) + } finally { + initial.close() + } + clock.advanceBy(2.seconds) + val receipt = storage.conflictState(mutationId) + val immutableAttempt = receipt.attempts.single().restartFingerprint() + val replayBackend = FakeBackend() + val restarted = + openConflictStore( + storage, + mutations, + replayBackend, + policy, + wallClock = clock, + keyResolver = keyResolver, + valueCodec = valueCodec, + ) + val afterHydration: ConflictState + val hydrationDeadLetters: List + val hydrationPushes: Int + val afterPass: ConflictState + val passDeadLetters: List + try { + restarted.pendingWrites() + afterHydration = storage.conflictState(mutationId) + hydrationDeadLetters = restarted.deadLetters().map(DeadLetter::restartFingerprint) + hydrationPushes = replayBackend.receivedPushes.size + drain(restarted, key) + afterPass = storage.conflictState(mutationId) + passDeadLetters = restarted.deadLetters().map(DeadLetter::restartFingerprint) + } finally { + restarted.close() + } + val secondReopenBackend = FakeBackend() + val secondReopen = + openConflictStore( + storage, + mutations, + secondReopenBackend, + policy, + wallClock = clock, + ) + val afterSecondReopen: ConflictState + val secondReopenDeadLetters: List + try { + secondReopenDeadLetters = secondReopen.deadLetters().map(DeadLetter::restartFingerprint) + afterSecondReopen = storage.conflictState(mutationId) + } finally { + secondReopen.close() + } + return RefreshRequiredRestartCase( + name = name, + mutationId = mutationId, + generation = receipt.execution.currentGeneration, + attempt = receipt.execution.attempt, + lastAttemptAt = receipt.execution.lastAttemptAt, + immutableAttempt = immutableAttempt, + afterHydration = afterHydration, + hydrationDeadLetters = hydrationDeadLetters, + hydrationPushes = hydrationPushes, + afterPass = afterPass, + passDeadLetters = passDeadLetters, + replayPushes = replayBackend.receivedPushes.size, + afterSecondReopen = afterSecondReopen, + secondReopenDeadLetters = secondReopenDeadLetters, + secondReopenPushes = secondReopenBackend.receivedPushes.size, + ) +} + +private fun assertRefreshRequiredRestartCases( + codec: RefreshRequiredRestartCase, + identity: RefreshRequiredRestartCase, +) { + val failures = mutableListOf() + listOf( + codec to (MutationFailureKind.CODEC to "value-codec-pre-ack"), + identity to (MutationFailureKind.IDENTITY to DRAIN_FAILURE_DETAIL_RESOLVER_NULL), + ).forEach { (observation, expected) -> + try { + assertRefreshRequiredRestartCase( + observation = observation, + kind = expected.first, + detail = expected.second, + ) + } catch (failure: AssertionError) { + failures += failure.message.orEmpty() + } + } + assertEquals(emptyList(), failures) +} + +private fun assertRefreshRequiredRestartCase( + observation: RefreshRequiredRestartCase, + kind: MutationFailureKind, + detail: String, +) { + with(observation) { + assertEquals(StoredPhase.REFRESH_REQUIRED, afterHydration.execution.phase, name) + assertEquals(generation, afterHydration.execution.currentGeneration, name) + assertEquals(attempt, afterHydration.execution.attempt, name) + assertEquals(lastAttemptAt, afterHydration.execution.lastAttemptAt, name) + assertEquals(immutableAttempt, afterHydration.attempts.single().restartFingerprint(), name) + assertEquals(emptyList(), afterHydration.failures, name) + assertNull(afterHydration.activeFailure, name) + assertEquals(emptyList(), hydrationDeadLetters, name) + assertEquals(0, hydrationPushes, name) + + assertEquals(StoredPhase.PARKED, afterPass.execution.phase, name) + assertEquals(generation, afterPass.execution.currentGeneration, name) + assertEquals(attempt, afterPass.execution.attempt, name) + assertEquals(lastAttemptAt, afterPass.execution.lastAttemptAt, name) + assertEquals(immutableAttempt, afterPass.attempts.single().restartFingerprint(), name) + assertEquals(1, afterPass.failures.size, name) + val activeFailure = assertNotNull(afterPass.activeFailure, name) + assertEquals(afterPass.failures.single().failureId, activeFailure.failureId, name) + assertEquals(generation, activeFailure.generation, name) + assertEquals(kind, activeFailure.kind, name) + assertEquals(detail, activeFailure.detail, name) + assertEquals(0, replayPushes, name) + assertEquals( + listOf(DeadLetterFingerprint(mutationId, generation, attempt, kind, detail)), + passDeadLetters, + name, + ) + + assertEquals(StoredPhase.PARKED, afterSecondReopen.execution.phase, name) + assertEquals(generation, afterSecondReopen.execution.currentGeneration, name) + assertEquals(attempt, afterSecondReopen.execution.attempt, name) + assertEquals(lastAttemptAt, afterSecondReopen.execution.lastAttemptAt, name) + assertEquals(immutableAttempt, afterSecondReopen.attempts.single().restartFingerprint(), name) + assertEquals(1, afterSecondReopen.failures.size, name) + assertEquals( + listOf(DeadLetterFingerprint(mutationId, generation, attempt, kind, detail)), + secondReopenDeadLetters, + name, + ) + assertEquals(0, secondReopenPushes, name) + } +} + +private fun MutationAttemptRecord.restartFingerprint(): AttemptFingerprint = + AttemptFingerprint( + generation = generation, + base = baseBlob?.toList(), + mine = mineBlob?.toList(), + key = generationIdempotencyKey, + conflictMetaPresent = conflictMetaPresent, + conflictWrittenAt = conflictWrittenAt, + conflictEtag = conflictEtag, + conflictReceivedAt = conflictReceivedAt, + ) + +private fun DeadLetter.restartFingerprint(): DeadLetterFingerprint = + DeadLetterFingerprint( + mutationId = mutationId, + generation = generation, + attempts = attempts, + kind = failure.kind, + detail = failure.detail, + ) + +private fun MutationPresence.toState(): MutationPresenceState = + if (this is MutationPresence.Present) MutationPresenceState.PRESENT else MutationPresenceState.ABSENT + +private fun MutationPresence.encode(codec: MutationCodec): ByteArray? = + (this as? MutationPresence.Present)?.let { codec.encode(it.value) } + +private fun MutationPresence.valueOrAbsent(): String = + (this as? MutationPresence.Present)?.value ?: "absent" + +private class CaptureTrace { + val events = mutableListOf() + var afterBarrier: Boolean = false +} + +private class TracingSourceOfTruth( + private val trace: CaptureTrace, +) : SourceOfTruth { + private val rows = MutableStateFlow>(emptyMap()) + + override fun reader(key: MutationsTestKey): Flow = + rows.map { values -> + if (trace.afterBarrier) trace.events += "local" + values[key.canonicalId()] + } + + override suspend fun write(key: MutationsTestKey, value: String) { + rows.update { it + (key.canonicalId() to value) } + } + + override suspend fun delete(key: MutationsTestKey) { + rows.update { it - key.canonicalId() } + } + + override suspend fun deleteNamespace(namespace: StoreNamespace) { + rows.value = emptyMap() + } + + override suspend fun deleteAll() { + rows.value = emptyMap() + } +} + +private class TracingBookkeeper( + private val trace: CaptureTrace, + private val delegate: MutationBookkeeper = MutationBookkeeper(), +) : Bookkeeper { + override suspend fun recordSuccess(key: StoreKey, meta: StoreMeta) = delegate.recordSuccess(key, meta) + + override suspend fun recordFailure(key: StoreKey, atEpochMillis: Long) = + delegate.recordFailure(key, atEpochMillis) + + override suspend fun status(key: StoreKey): KeyStatus? { + if (trace.afterBarrier) trace.events += "status" + return delegate.status(key) + } + + override suspend fun forget(key: StoreKey) = delegate.forget(key) + + override suspend fun markStale(key: StoreKey) = delegate.markStale(key) + + override suspend fun advanceStaleWatermark(namespace: StoreNamespace) = + delegate.advanceStaleWatermark(namespace) + + override suspend fun advanceGlobalStaleWatermark() = delegate.advanceGlobalStaleWatermark() + + override suspend fun forgetNamespace(namespace: StoreNamespace) = delegate.forgetNamespace(namespace) + + override suspend fun forgetAll() = delegate.forgetAll() +} + +private class CountingBookkeeper( + private val delegate: MutationBookkeeper = MutationBookkeeper(), +) : Bookkeeper { + var markStaleCalls: Int = 0 + private set + + override suspend fun recordSuccess(key: StoreKey, meta: StoreMeta) = delegate.recordSuccess(key, meta) + + override suspend fun recordFailure(key: StoreKey, atEpochMillis: Long) = + delegate.recordFailure(key, atEpochMillis) + + override suspend fun status(key: StoreKey): KeyStatus? = delegate.status(key) + + override suspend fun forget(key: StoreKey) = delegate.forget(key) + + override suspend fun markStale(key: StoreKey) { + markStaleCalls += 1 + delegate.markStale(key) + } + + override suspend fun advanceStaleWatermark(namespace: StoreNamespace) = + delegate.advanceStaleWatermark(namespace) + + override suspend fun advanceGlobalStaleWatermark() = delegate.advanceGlobalStaleWatermark() + + override suspend fun forgetNamespace(namespace: StoreNamespace) = delegate.forgetNamespace(namespace) + + override suspend fun forgetAll() = delegate.forgetAll() +} + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = 25.seconds, testBody = testBody) diff --git a/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationDrainInvalidationTest.kt b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationDrainInvalidationTest.kt new file mode 100644 index 000000000..1ffbf108c --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationDrainInvalidationTest.kt @@ -0,0 +1,692 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, +) + +package org.mobilenativefoundation.store6.mutations + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import kotlinx.coroutines.withContext +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreException +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.seam.StoreResults +import org.mobilenativefoundation.store6.core.seam.StoreWriteHandle +import org.mobilenativefoundation.store6.mutations.storage.InMemoryMutationJournalStorage +import org.mobilenativefoundation.store6.mutations.storage.MutationAliasState +import org.mobilenativefoundation.store6.mutations.storage.MutationClientRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationEffectDisposition +import org.mobilenativefoundation.store6.mutations.storage.MutationEffectRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionPhase as StoredPhase +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationFailureRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationJournalStorage +import org.mobilenativefoundation.store6.mutations.storage.MutationKeyAliasRecord +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +/** The durable invalidation executor and post-ack failure-containment contract. */ +class MutationDrainInvalidationTest { + @Test + fun keyEffect_usesWriteHandleMarkStale() = runTest { + val errors = mutableListOf() + val source = MutationsTestKey("key-effect-source") + val target = MutationsTestKey("key-effect-target") + val directMutations = InvalidationMutations(staleKeys = setOf(target)) + val directStorage = InMemoryMutationJournalStorage() + val directHandle = RecordingInvalidationHandle() + val direct = + openInvalidationEngine( + storage = directStorage, + mutations = directMutations, + backend = retainingInvalidationBackend(), + handle = directHandle, + ) + val directId = direct.mutate(source, directMutations.set, "accepted") + + direct.drain(source) + + val directState = directStorage.invalidationState(directId) + expect(errors, "typed KEY effect reaches StoreWriteHandle.markStale") { + assertEquals(listOf(target.identity()), directHandle.markAttempts) + assertEquals(MutationEffectDisposition.APPLIED, directState.effects.single().disposition) + assertEquals(StoredPhase.RETIRED, directState.execution.phase) + } + + val streamMutations = InvalidationMutations(staleKeys = setOf(target)) + val streamStorage = InMemoryMutationJournalStorage() + val streamBackend = retainingInvalidationBackend() + val users = openInvalidationStore(streamStorage, streamMutations, streamBackend) + assertEquals("base", users.get(target)) + val refreshedValue = "key-effect-refreshed" + val probe = observeRefresh(users, target, refreshedValue) + val refreshStarted = CompletableDeferred() + val releaseRefresh = CompletableDeferred() + try { + val initial = probe.initial.await() + assertEquals("base", initial.value) + assertTrue(!initial.isStale) + assertTrue(!initial.refreshing) + streamBackend.seed(target, refreshedValue) + streamBackend.loadGate = { key -> + if (key.identity() == target.identity()) { + refreshStarted.complete(Unit) + releaseRefresh.await() + } + } + val streamId = users.mutate(source, streamMutations.set, "accepted") + + users.drain(source) + refreshStarted.awaitFromDefault() + releaseRefresh.complete(Unit) + val refreshed = probe.refreshed.awaitFromDefault() + + val streamState = streamStorage.invalidationState(streamId) + expect(errors, "active target stream observes the KEY invalidation refresh") { + assertEquals(refreshedValue, refreshed.value) + assertTrue(!refreshed.isStale) + assertTrue(!refreshed.refreshing) + assertEquals(StoredPhase.RETIRED, streamState.execution.phase) + } + } finally { + releaseRefresh.complete(Unit) + streamBackend.loadGate = null + probe.collector.cancelAndJoin() + users.close() + } + assertTrue(errors.isEmpty(), errors.joinToString(separator = "\n")) + } + + @Test + fun namespaceEffect_usesDelegateInvalidateNamespace() = runTest { + val errors = mutableListOf() + val namespace = StoreNamespace("mutations") + val source = MutationsTestKey("namespace-effect-source", namespace) + val resident = MutationsTestKey("namespace-effect-resident", namespace) + val mutations = InvalidationMutations(staleNamespaces = setOf(namespace)) + val storage = InMemoryMutationJournalStorage() + val backend = retainingInvalidationBackend() + val users = openInvalidationStore(storage, mutations, backend) + assertEquals("base", users.get(resident)) + val refreshedValue = "namespace-effect-refreshed" + val probe = observeRefresh(users, resident, refreshedValue) + val refreshStarted = CompletableDeferred() + val releaseRefresh = CompletableDeferred() + try { + val initial = probe.initial.await() + assertEquals("base", initial.value) + assertTrue(!initial.isStale) + assertTrue(!initial.refreshing) + backend.seed(resident, refreshedValue) + backend.loadGate = { key -> + if (key.identity() == resident.identity()) { + refreshStarted.complete(Unit) + releaseRefresh.await() + } + } + val mutationId = users.mutate(source, mutations.set, "accepted") + + users.drain(source) + + val state = storage.invalidationState(mutationId) + val effectApplied = + state.effects.single().disposition == MutationEffectDisposition.APPLIED + expect(errors, "NAMESPACE effect becomes APPLIED") { + assertEquals(MutationEffectDisposition.APPLIED, state.effects.single().disposition) + assertEquals(StoredPhase.RETIRED, state.execution.phase) + } + if (effectApplied) { + refreshStarted.awaitFromDefault() + releaseRefresh.complete(Unit) + val refreshed = probe.refreshed.awaitFromDefault() + expect(errors, "delegated namespace invalidation refreshes an active resident stream") { + assertEquals(refreshedValue, refreshed.value) + assertTrue(!refreshed.isStale) + assertTrue(!refreshed.refreshing) + } + } else { + errors += + "delegated namespace invalidation refreshes an active resident stream: " + + "NAMESPACE stayed PENDING; delegate refetch and refreshed Data did not run" + } + } finally { + releaseRefresh.complete(Unit) + backend.loadGate = null + probe.collector.cancelAndJoin() + users.close() + } + assertTrue(errors.isEmpty(), errors.joinToString(separator = "\n")) + } + + @Test + fun pendingEffect_replaysBeforeRetirement() = runTest { + val errors = mutableListOf() + val target = MutationsTestKey("restart-pending-target") + val source = MutationsTestKey("restart-pending-source") + val mutations = InvalidationMutations(staleKeys = setOf(target)) + val backing = InMemoryMutationJournalStorage() + val storage = FailPointJournalStorage(backing) + val backend = retainingInvalidationBackend() + val firstHandle = RecordingInvalidationHandle() + val first = openInvalidationEngine(storage, mutations, backend, firstHandle) + val mutationId = first.mutate(source, mutations.set, "accepted") + storage.armFailTransaction(JournalFailPointBoundary.EFFECT_MARKER) + + val stagedFailure = captureFailure { first.drain(source) } + val pending = backing.invalidationState(mutationId) + expect(errors, "marker failure leaves durable effect work replayable") { + assertIs(assertNotNull(stagedFailure)) + assertEquals(listOf(target.identity()), firstHandle.markAttempts) + assertEquals(StoredPhase.EFFECTS_PENDING, pending.execution.phase) + assertEquals(MutationEffectDisposition.PENDING, pending.effects.single().disposition) + assertEquals(0L, assertNotNull(pending.client).retiredThroughSequence) + } + + val replayHandle = RecordingInvalidationHandle() + val reopened = openInvalidationEngine(storage, mutations, backend, replayHandle) + reopened.drain(source) + + val completed = backing.invalidationState(mutationId) + expect(errors, "restart executes PENDING before retirement") { + assertEquals(listOf(target.identity()), replayHandle.markAttempts) + assertEquals(MutationEffectDisposition.APPLIED, completed.effects.single().disposition) + assertEquals(StoredPhase.RETIRED, completed.execution.phase) + assertEquals(1L, assertNotNull(completed.client).retiredThroughSequence) + assertEquals(1, backend.receivedPushes.size) + } + assertTrue(errors.isEmpty(), errors.joinToString(separator = "\n")) + } + + @Test + fun pendingEffect_blocksRetirementUntilTerminal() = runTest { + val errors = mutableListOf() + val target = MutationsTestKey("blocking-target") + val source = MutationsTestKey("blocking-source") + val mutations = InvalidationMutations(staleKeys = setOf(target)) + val storage = InMemoryMutationJournalStorage() + val handle = RecordingInvalidationHandle(remainingTargetFailures = 2) + val engine = + openInvalidationEngine( + storage, + mutations, + retainingInvalidationBackend(), + handle, + ) + val mutationId = engine.mutate(source, mutations.set, "accepted") + + repeat(2) { pass -> + val exposed = captureFailure { engine.drain(source) } + val held = storage.invalidationState(mutationId) + expect(errors, "failed target pass ${pass + 1} is surfaced") { + val wrapper = assertIs(assertNotNull(exposed)) + assertIs(wrapper.error) + } + expect(errors, "failed target pass ${pass + 1} holds EFFECTS_PENDING") { + assertEquals(StoredPhase.EFFECTS_PENDING, held.execution.phase) + assertEquals(MutationEffectDisposition.PENDING, held.effects.single().disposition) + assertEquals(0L, assertNotNull(held.client).retiredThroughSequence) + } + } + + engine.drain(source) + + val completed = storage.invalidationState(mutationId) + expect(errors, "retirement follows terminal effect success") { + assertEquals(3, handle.markAttempts.size) + assertEquals(MutationEffectDisposition.APPLIED, completed.effects.single().disposition) + assertEquals(StoredPhase.RETIRED, completed.execution.phase) + assertEquals(1L, assertNotNull(completed.client).retiredThroughSequence) + } + assertTrue(errors.isEmpty(), errors.joinToString(separator = "\n")) + } + + @Test + fun currentAckPendingAlias_resolvesKeyEffectBeforeFacadeActivation() = runTest { + val errors = mutableListOf() + val source = MutationsTestKey("pending-alias-source") + val target = MutationsTestKey("pending-alias-target") + val mutations = InvalidationMutations(staleKeys = setOf(source)) + val storage = InMemoryMutationJournalStorage() + val backend = retainingInvalidationBackend() + backend.pushBehavior = { _, value -> + MutationPresentAck(value, "pending-alias-etag", target) + } + val targetEntered = CompletableDeferred() + val releaseTarget = CompletableDeferred() + val handle = + RecordingInvalidationHandle( + beforeMark = { + targetEntered.complete(Unit) + releaseTarget.await() + }, + ) + val engine = openInvalidationEngine(storage, mutations, backend, handle) + val mutationId = engine.mutate(source, mutations.set, "accepted") + val drain = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.drain(source) + } + + targetEntered.await() + val held = storage.invalidationState(mutationId) + expect(errors, "effect work sees the current acknowledgement's pending alias") { + assertEquals(listOf(target.identity()), handle.markAttempts) + } + expect(errors, "facade routing remains provisional before finalization") { + assertEquals(StoredPhase.EFFECTS_PENDING, held.execution.phase) + assertEquals(MutationAliasState.PENDING, held.aliases.single().state) + assertEquals(source.identity(), engine.terminalIdentityOf(source.identity())) + } + + releaseTarget.complete(Unit) + drain.await() + val completed = storage.invalidationState(mutationId) + expect(errors, "finalization activates the alias only after the effect marker") { + assertEquals(MutationEffectDisposition.APPLIED, completed.effects.single().disposition) + assertEquals(MutationAliasState.ACTIVE, completed.aliases.single().state) + assertEquals(StoredPhase.RETIRED, completed.execution.phase) + assertEquals(target.identity(), engine.terminalIdentityOf(source.identity())) + } + assertTrue(errors.isEmpty(), errors.joinToString(separator = "\n")) + } + + @Test + fun serverWins_skipsEveryPendingEffect_withoutInvokingInvalidation_andAdvancesRetiredPrefix() = + runTest { + val errors = mutableListOf() + val source = MutationsTestKey("server-wins-source") + val target = MutationsTestKey("server-wins-target") + val mutations = + InvalidationMutations( + staleKeys = setOf(target), + staleNamespaces = setOf(source.namespace), + ) + val storage = InMemoryMutationJournalStorage() + val backend = retainingInvalidationBackend() + backend.pushBehavior = { _, _ -> throw conflictFailure() } + val handle = RecordingInvalidationHandle() + val engine = + openInvalidationEngine( + storage = storage, + mutations = mutations, + backend = backend, + handle = handle, + conflicts = + MutationConflictRegistration( + precondition = null, + merge = { _, _, _ -> MutationConflictResolution.ServerWins }, + ), + ) + val mutationId = engine.mutate(source, mutations.set, "mine") + + engine.drain(source) + val conflicted = storage.invalidationState(mutationId) + expect(errors, "first conflict pass preserves every pending effect") { + assertEquals(StoredPhase.REFRESH_REQUIRED, conflicted.execution.phase) + assertEquals(2, conflicted.effects.size) + assertTrue( + conflicted.effects.all { + it.disposition == MutationEffectDisposition.PENDING + }, + ) + } + + engine.drain(source) + + val retired = storage.invalidationState(mutationId) + expect(errors, "server-wins skips all effects and advances the local prefix") { + assertEquals(StoredPhase.RETIRED, retired.execution.phase) + assertEquals(2, retired.effects.size) + assertTrue( + retired.effects.all { + it.disposition == MutationEffectDisposition.SKIPPED + }, + ) + assertEquals(1L, assertNotNull(retired.client).retiredThroughSequence) + } + expect(errors, "server-wins invokes no invalidation target") { + assertTrue(handle.markAttempts.isEmpty()) + assertEquals(1, backend.receivedPushes.size) + } + assertTrue(errors.isEmpty(), errors.joinToString(separator = "\n")) + } + + @Test + fun effectResolverFailure_staysPendingAndDoesNotPark() = runTest { + val errors = mutableListOf() + val source = MutationsTestKey("resolver-failure-source") + val target = MutationsTestKey("resolver-failure-target") + val mutations = InvalidationMutations(staleKeys = setOf(target)) + val storage = InMemoryMutationJournalStorage() + val resolverFailure = IllegalStateException("effect target cannot be resolved") + val resolver = + MutationKeyResolver { identity -> + if (identity.canonicalId == target.canonicalId()) { + throw resolverFailure + } + MutationsTestKeyResolver.resolve(identity) + } + val engine = + openInvalidationEngine( + storage = storage, + mutations = mutations, + backend = FakeBackend(), + handle = RecordingInvalidationHandle(), + resolver = resolver, + ) + val mutationId = engine.mutate(source, mutations.set, "accepted") + + val exposed = captureFailure { engine.drain(source) } + val held = storage.invalidationState(mutationId) + expect(errors, "resolver failure is surfaced from keyed drain") { + val wrapper = assertIs(assertNotNull(exposed)) + val structured = assertIs(wrapper.error) + assertSame(resolverFailure, wrapper.cause) + assertSame(resolverFailure, structured.cause) + } + expect(errors, "resolver failure records IDENTITY evidence and stays pending") { + assertEquals(StoredPhase.EFFECTS_PENDING, held.execution.phase) + assertEquals(MutationEffectDisposition.PENDING, held.effects.single().disposition) + assertTrue(held.failures.any { it.kind == MutationFailureKind.IDENTITY }) + } + expect(errors, "post-ack resolver failure never parks") { + assertTrue(engine.deadLetters().isEmpty()) + assertEquals(null, held.execution.activeFailureId) + assertEquals(0L, assertNotNull(held.client).retiredThroughSequence) + } + assertTrue(errors.isEmpty(), errors.joinToString(separator = "\n")) + } + + @Test + fun effectTargetFailure_staysPendingAndRetriesAtLeastOnce() = runTest { + val errors = mutableListOf() + val source = MutationsTestKey("target-failure-source") + val target = MutationsTestKey("target-failure-target") + val mutations = InvalidationMutations(staleKeys = setOf(target)) + val storage = InMemoryMutationJournalStorage() + val targetFailure = IllegalStateException("injected invalidation target failure") + val handle = RecordingInvalidationHandle(nextTargetFailure = targetFailure) + val engine = + openInvalidationEngine( + storage, + mutations, + retainingInvalidationBackend(), + handle, + ) + val mutationId = engine.mutate(source, mutations.set, "accepted") + + val exposed = captureFailure { engine.drain(source) } + val held = storage.invalidationState(mutationId) + expect(errors, "target failure is surfaced and retains replayable work") { + val wrapper = assertIs(assertNotNull(exposed)) + val structured = assertIs(wrapper.error) + assertSame(targetFailure, wrapper.cause) + assertSame(targetFailure, structured.cause) + assertEquals(StoredPhase.EFFECTS_PENDING, held.execution.phase) + assertEquals(MutationEffectDisposition.PENDING, held.effects.single().disposition) + } + expect(errors, "target failure records EFFECT evidence without parking") { + val durableFailure = held.failures.single { it.kind == MutationFailureKind.EFFECT } + assertEquals(targetFailure.message, durableFailure.message) + assertTrue(engine.deadLetters().isEmpty()) + } + + engine.drain(source) + + val completed = storage.invalidationState(mutationId) + expect(errors, "later pass re-executes the target and reaches terminal state") { + assertEquals(listOf(target.identity(), target.identity()), handle.markAttempts) + assertEquals(MutationEffectDisposition.APPLIED, completed.effects.single().disposition) + assertEquals(StoredPhase.RETIRED, completed.execution.phase) + } + assertTrue(errors.isEmpty(), errors.joinToString(separator = "\n")) + } + + @Test + fun effectCancellation_rethrowsAndLeavesPending() = runTest { + val source = MutationsTestKey("effect-cancellation-source") + val target = MutationsTestKey("effect-cancellation-target") + val mutations = InvalidationMutations(staleKeys = setOf(target)) + val storage = InMemoryMutationJournalStorage() + val cancellation = CancellationException("cancel effect target") + val handle = RecordingInvalidationHandle(nextTargetFailure = cancellation) + val engine = openInvalidationEngine(storage, mutations, FakeBackend(), handle) + val mutationId = engine.mutate(source, mutations.set, "accepted") + + val thrown = assertFailsWith { engine.drain(source) } + + val held = storage.invalidationState(mutationId) + assertSame(cancellation, thrown) + assertEquals(StoredPhase.EFFECTS_PENDING, held.execution.phase) + assertEquals(MutationEffectDisposition.PENDING, held.effects.single().disposition) + assertTrue(held.failures.isEmpty()) + assertTrue(engine.deadLetters().isEmpty()) + assertEquals(0L, assertNotNull(held.client).retiredThroughSequence) + } +} + +private class InvalidationMutations( + staleKeys: Set = emptySet(), + staleNamespaces: Set = emptySet(), +) { + lateinit var set: MutatorRef + + val registry: MutatorRegistry = + mutatorRegistry { + set = + upsert( + id = "invalidation-set", + version = 1, + codec = FixtureStringArgsCodec, + stales = + typedStales( + keys = staleKeys, + namespaces = staleNamespaces, + ), + ) { _, value -> MutationPresence.Present(value) } + } +} + +private class RecordingInvalidationHandle( + var remainingTargetFailures: Int = 0, + var nextTargetFailure: Throwable? = null, + private val beforeMark: suspend (MutationsTestKey) -> Unit = {}, +) : StoreWriteHandle { + val markAttempts = mutableListOf() + + override suspend fun apply( + key: MutationsTestKey, + value: String, + ) = Unit + + override suspend fun markStale(key: MutationsTestKey) { + markAttempts += key.identity() + beforeMark(key) + nextTargetFailure?.let { failure -> + nextTargetFailure = null + throw failure + } + if (remainingTargetFailures > 0) { + remainingTargetFailures -= 1 + throw IllegalStateException("injected invalidation target failure") + } + } + + override suspend fun confirmFresh( + key: MutationsTestKey, + etag: String?, + ) = Unit +} + +private fun retainingInvalidationBackend(): FakeBackend = + FakeBackend().apply { + retireBehavior = { + MutationRetirementAck(confirmedThroughSequence = 0L) + } + } + +private fun openInvalidationEngine( + storage: MutationJournalStorage, + mutations: InvalidationMutations, + backend: FakeBackend, + handle: RecordingInvalidationHandle, + resolver: MutationKeyResolver = MutationsTestKeyResolver, + conflicts: MutationConflictRegistration? = null, +): MutationEngine { + val journal = + StorageBackedMutationJournal( + storage = storage, + registrations = mutations.registry.registrations, + clientId = INVALIDATION_CLIENT_ID, + hydrateOnFirstUse = true, + ) + return MutationEngine( + registry = mutations.registry, + server = backend, + journal = journal, + keyResolver = resolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + conflicts = conflicts, + baseReader = { "base" }, + clientId = INVALIDATION_CLIENT_ID, + ).also { it.bind(handle) } +} + +private fun openInvalidationStore( + storage: MutationJournalStorage, + mutations: InvalidationMutations, + backend: FakeBackend, +): MutationStore = + mutationStore( + registry = mutations.registry, + server = backend, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + ) { + fetcherOfResult { backend.loadResult(it) } + journalStorage(storage) + } + +private data class RefreshProbe( + val initial: CompletableDeferred>, + val refreshed: CompletableDeferred>, + val collector: Job, +) + +private fun TestScope.observeRefresh( + store: MutationStore, + key: MutationsTestKey, + refreshedValue: String, +): RefreshProbe { + val initial = CompletableDeferred>() + val refreshed = CompletableDeferred>() + val collector = + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + store.stream(key).collect { result -> + if (result is StoreResult.Data) { + initial.complete(result) + if (result.value == refreshedValue && !result.isStale) { + refreshed.complete(result) + } + } + } + } + return RefreshProbe(initial, refreshed, collector) +} + +// Preserve Default-dispatch ordering and let the suite-level runTest bound own cancellation. +private suspend fun CompletableDeferred.awaitFromDefault(): T = + withContext(Dispatchers.Default) { + await() + } + +private data class InvalidationState( + val client: MutationClientRecord?, + val execution: MutationExecutionRecord, + val effects: List, + val failures: List, + val aliases: List, +) + +private suspend fun MutationJournalStorage.invalidationState( + mutationId: String, +): InvalidationState = + transaction { transaction -> + val intent = + transaction.intents(INVALIDATION_CLIENT_ID).single { + it.mutationId == mutationId + } + val sequence = intent.clientSequence + InvalidationState( + client = transaction.client(INVALIDATION_CLIENT_ID), + execution = + transaction.executions(INVALIDATION_CLIENT_ID).single { + it.clientSequence == sequence + }, + effects = + transaction.effects(INVALIDATION_CLIENT_ID).filter { + it.clientSequence == sequence + }, + failures = + transaction.failures(INVALIDATION_CLIENT_ID).filter { + it.clientSequence == sequence + }, + aliases = + transaction.aliases().filter { + it.createdByClientId == INVALIDATION_CLIENT_ID && + it.createdBySequence == sequence + }, + ) + } + +private fun conflictFailure(): StoreException = + StoreResults.exception( + StoreResults.conflict(serverMeta = null, message = "invalidation server conflict"), + IllegalStateException("backend conflict cause"), + ) + +private suspend fun captureFailure(block: suspend () -> Unit): Throwable? = + try { + block() + null + } catch (failure: Throwable) { + failure + } + +private suspend fun expect( + errors: MutableList, + label: String, + assertion: suspend () -> Unit, +) { + try { + assertion() + } catch (failure: Throwable) { + errors += "$label: ${failure.message}" + } +} + +private const val INVALIDATION_CLIENT_ID = "client-0" +private val INVALIDATION_TEST_TIMEOUT = 25.seconds + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = INVALIDATION_TEST_TIMEOUT) { testBody() } diff --git a/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationDrainParkingTest.kt b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationDrainParkingTest.kt new file mode 100644 index 000000000..7587f3ae9 --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationDrainParkingTest.kt @@ -0,0 +1,1007 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, +) + +package org.mobilenativefoundation.store6.mutations + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.seam.StoreWriteHandle +import org.mobilenativefoundation.store6.core.seam.WallClock +import org.mobilenativefoundation.store6.mutations.storage.InMemoryMutationJournalStorage +import org.mobilenativefoundation.store6.mutations.storage.MutationAttemptRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationClientRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionPhase as StoredPhase +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationFailureRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationJournalStorage +import org.mobilenativefoundation.store6.testing.TestWallClock +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +/** The complete pre-ack parking inventory. */ +class MutationDrainParkingTest { + @Test + fun unresolvedPreAckIdentityParksWithoutBlockingOtherIdentities() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = ParkingMutations() + val backend = FakeBackend() + val unresolved = MutationsTestKey("unresolved") + val healthy = MutationsTestKey("healthy") + val resolver = + MutationKeyResolver { identity -> + if (identity.canonicalId == unresolved.canonicalId()) { + throw IllegalStateException("identity lookup failed") + } + MutationsTestKeyResolver.resolve(identity) + } + val engine = openParkingEngine(storage, mutations, backend, resolver) + val unresolvedId = engine.mutate(unresolved, mutations.append, "+lost") + val healthyId = engine.mutate(healthy, mutations.append, "+ok") + engine.clearLiveKeyCache() + + engine.drain() + + assertEquals(listOf(healthy.canonicalId()), backend.receivedPushes.map { it.identity.canonicalId }) + assertEquals(listOf("base+ok"), backend.pushedValues) + assertEquals(emptyList(), engine.pendingWrites()) + val dead = engine.deadLetters().single() + assertEquals(unresolvedId, dead.mutationId) + assertEquals(MutationFailureKind.IDENTITY, dead.failure.kind) + assertEquals(DRAIN_FAILURE_DETAIL_RESOLVER_THROW, dead.failure.detail) + assertEquals(StoredPhase.PARKED, storedState(storage, unresolvedId).execution.phase) + assertEquals(StoredPhase.RETIRED, storedState(storage, healthyId).execution.phase) + + val reopened = openParkingEngine(storage, mutations, FakeBackend()) + assertEquals(unresolvedId, reopened.deadLetters().single().mutationId) + assertEquals(emptyList(), reopened.pendingWrites()) + } + + @Test + fun unknownPreAckValueCodecVersion_parksWithoutTransport() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = ParkingMutations() + seedParkingStorage( + storage, + ParkingSeed( + sequence = 1L, + canonicalId = "unknown-value-codec", + mutationId = "unknown-value-codec", + mutatorId = mutations.append.id, + mutatorVersion = 1, + argsBlob = "+mine".encodeToByteArray(), + phase = StoredPhase.READY, + valueCodecVersion = 99, + ), + ) + val codec = StrictVersionOneStringCodec() + val backend = FakeBackend() + val engine = openParkingEngine(storage, mutations, backend, valueCodec = codec) + + assertEquals(listOf("unknown-value-codec"), engine.pendingWrites().map { it.mutationId }) + assertTrue(99 in codec.decodeVersions) + engine.drain() + + assertEquals(emptyList(), backend.receivedPushes) + val dead = engine.deadLetters().single() + assertEquals(MutationFailureKind.CODEC, dead.failure.kind) + assertEquals("value-codec-pre-ack", dead.failure.detail) + val state = storedState(storage, dead.mutationId) + assertEquals(StoredPhase.PARKED, state.execution.phase) + assertNotNull(state.execution.activeFailureId) + assertEquals(state.execution.activeFailureId, state.activeFailure?.failureId) + } + + @Test + fun unknownMutatorOrArgsVersion_parksWithoutTransport() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = ParkingMutations() + seedParkingStorage( + storage, + ParkingSeed(1L, "missing-mutator", "missing-mutator", "removed-mutator", 1, byteArrayOf()), + ParkingSeed( + 2L, + "unknown-args-version", + "unknown-args-version", + mutations.append.id, + 99, + "+suffix".encodeToByteArray(), + ), + ) + val backend = FakeBackend() + val engine = openParkingEngine(storage, mutations, backend) + + assertEquals(2, engine.pendingWrites().size) + engine.drain() + + assertEquals(emptyList(), backend.receivedPushes) + val dead = engine.deadLetters() + assertEquals(2, dead.size) + assertEquals(setOf("mutator-missing", "args-codec"), dead.map { it.failure.detail }.toSet()) + assertTrue(dead.all { it.failure.kind == MutationFailureKind.CODEC }) + assertTrue(dead.all { storedState(storage, it.mutationId).execution.phase == StoredPhase.PARKED }) + } + + @Test + fun abandonedInflightResolverFailure_parksWithoutAdvancingAttemptFacts() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = ParkingMutations() + val firstBackend = FakeBackend().apply { + pushBehavior = { _, _ -> throw CancellationException("push outcome unknown") } + } + val key = MutationsTestKey("abandoned-inflight") + val first = openParkingEngine(storage, mutations, firstBackend) + val mutationId = first.mutate(key, mutations.append, "+mine") + assertFailsWith { first.drain(key) } + val before = storedState(storage, mutationId) + val attemptBefore = assertNotNull(before.attempt).snapshot() + assertEquals(StoredPhase.INFLIGHT, before.execution.phase) + assertEquals(0, before.execution.attempt) + assertNull(before.execution.lastAttemptAt) + assertEquals(1, firstBackend.receivedPushes.size) + + val replayBackend = FakeBackend() + val reopened = + openParkingEngine( + storage, + mutations, + replayBackend, + resolver = MutationKeyResolver { throw IllegalStateException("resolver cache was lost") }, + ) + reopened.drain() + + assertEquals(emptyList(), replayBackend.receivedPushes) + val after = storedState(storage, mutationId) + assertEquals(StoredPhase.PARKED, after.execution.phase) + assertEquals(before.execution.currentGeneration, after.execution.currentGeneration) + assertEquals(before.execution.attempt, after.execution.attempt) + assertEquals(before.execution.lastAttemptAt, after.execution.lastAttemptAt) + assertEquals(attemptBefore, assertNotNull(after.attempt).snapshot()) + assertEquals(MutationFailureKind.IDENTITY, assertNotNull(after.activeFailure).kind) + val dead = reopened.deadLetters().single() + assertEquals(1, dead.generation) + assertEquals(0, dead.attempts) + + val twiceReopened = openParkingEngine(storage, mutations, FakeBackend()) + assertEquals(mutationId, twiceReopened.deadLetters().single().mutationId) + } + + @Test + fun declinedHead_blocksOnlyItsSameEffectiveKeySuffix() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = ParkingMutations() + val backend = FakeBackend() + val declined = MutationsTestKey("declined") + val healthy = MutationsTestKey("decline-independent") + val bases = mutableMapOf(healthy.canonicalId() to "healthy") + val engine = + openParkingEngine( + storage, + mutations, + backend, + baseReader = { key -> bases[key.canonicalId()] }, + handle = MapBackedParkingHandle(bases), + ) + val head = engine.mutate(declined, mutations.strictUpdate, "+head") + val suffix = engine.mutate(declined, mutations.append, "+suffix") + engine.mutate(healthy, mutations.append, "+ok") + + engine.drain() + + assertEquals(listOf("healthy+ok"), backend.pushedValues) + assertEquals(listOf(head, suffix), engine.pending(declined).map { it.mutationId }) + assertEquals(emptyList(), engine.deadLetters()) + + bases[declined.canonicalId()] = "confirmed" + engine.drain(declined) + + assertEquals(listOf("healthy+ok", "confirmed+head", "confirmed+head+suffix"), backend.pushedValues) + assertEquals(emptyList(), engine.pending(declined)) + } + + @Test + fun parkedHead_isRemovedFromProjection_andSameKeySuffixRebasesAndPushes() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = ParkingMutations() + val backend = FakeBackend() + val key = MutationsTestKey("park-and-rebase") + var resolverFails = true + val engine = + openParkingEngine( + storage, + mutations, + backend, + resolver = MutationKeyResolver { identity -> + if (resolverFails) throw IllegalStateException("temporarily unresolved") + MutationsTestKeyResolver.resolve(identity) + }, + ) + val head = engine.mutate(key, mutations.append, "+parked") + val suffix = engine.mutate(key, mutations.append, "+suffix") + assertEquals("base+parked+suffix", engine.overlay.apply(key, "base")) + engine.clearLiveKeyCache() + + engine.drain() + + assertEquals(head, engine.deadLetters().single().mutationId) + assertEquals(listOf(suffix), engine.pending(key).map { it.mutationId }) + assertEquals("base+suffix", engine.overlay.apply(key, "base")) + assertEquals(emptyList(), backend.receivedPushes) + + resolverFails = false + engine.drain(key) + assertEquals(listOf("base+suffix"), backend.pushedValues) + assertEquals(emptyList(), engine.pending(key)) + } + + @Test + fun parkedSequence_pinsRetiredPrefix_butNotSameKeyExecution() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = ParkingMutations() + val backend = FakeBackend() + val key = MutationsTestKey("parked-prefix") + val engine = + openParkingEngine( + storage, + mutations, + backend, + resolver = MutationKeyResolver { throw IllegalStateException("park first sequence") }, + ) + val parked = engine.mutate(key, mutations.append, "+parked") + val later = engine.mutate(key, mutations.append, "+later") + engine.clearLiveKeyCache() + engine.drain() + engine.drain(key) + + assertEquals(StoredPhase.PARKED, storedState(storage, parked).execution.phase) + assertEquals(StoredPhase.RETIRED, storedState(storage, later).execution.phase) + assertEquals(0L, storage.transaction { assertNotNull(it.client(PARKING_CLIENT_ID)).retiredThroughSequence }) + assertEquals(0L, backend.receivedPushes.single().retiredThroughSequence) + assertEquals(listOf("base+later"), backend.pushedValues) + } + + @Test + fun parkedSequence_isNeverRetriedInPlace() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = ParkingMutations() + val backend = FakeBackend() + val key = MutationsTestKey("never-retry-parked") + var resolverFails = true + val engine = + openParkingEngine( + storage, + mutations, + backend, + resolver = MutationKeyResolver { identity -> + if (resolverFails) throw IllegalStateException("park it") + MutationsTestKeyResolver.resolve(identity) + }, + ) + val parked = engine.mutate(key, mutations.append, "+old") + engine.clearLiveKeyCache() + engine.drain() + val original = storedState(storage, parked) + resolverFails = false + + engine.drain() + engine.drain(key) + assertEquals(emptyList(), backend.receivedPushes) + assertEquals(1, storage.transaction { it.failures(PARKING_CLIENT_ID).size }) + + val compensation = engine.mutate(key, mutations.append, "+replacement") + engine.drain(key) + + assertNotEquals(parked, compensation) + assertEquals(listOf("base+replacement"), backend.pushedValues) + val unchanged = storedState(storage, parked) + assertEquals(StoredPhase.PARKED, unchanged.execution.phase) + assertEquals(original.execution.activeFailureId, unchanged.execution.activeFailureId) + assertEquals(1, storage.transaction { it.failures(PARKING_CLIENT_ID).size }) + } + + @Test + fun terminalExecutionFailureProducesOneDeadLetter() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = ParkingMutations() + val clock = TestWallClock(startEpochMillis = 4_242L) + val rawMessage = "terminal\tfailure\u0007" + "x".repeat(2_000) + "\nat stack(File.kt:1)" + val engine = + openParkingEngine( + storage, + mutations, + FakeBackend(), + resolver = MutationKeyResolver { throw IllegalStateException(rawMessage) }, + wallClock = clock, + ) + val key = MutationsTestKey("one-dead-letter") + val mutationId = engine.mutate(key, mutations.append, "+never") + engine.clearLiveKeyCache() + + engine.drain() + engine.drain() + engine.drain(key) + + val dead = engine.deadLetters().single() + assertEquals(mutationId, dead.mutationId) + assertEquals(MutationFailureKind.IDENTITY, dead.failure.kind) + assertEquals(DRAIN_FAILURE_DETAIL_RESOLVER_THROW, dead.failure.detail) + assertTrue(dead.failure.detail.encodeToByteArray().size <= 128) + assertTrue(dead.failure.message.encodeToByteArray().size <= 1_024) + assertTrue(dead.failure.message.none(Char::isISOControl)) + assertFalse(dead.failure.message.contains("at stack")) + assertFalse(dead.failure.message.contains("IllegalStateException")) + assertEquals(4_242L, dead.failure.occurredAtEpochMillis) + assertEquals(1, storage.transaction { it.failures(PARKING_CLIENT_ID).size }) + + val reopened = openParkingEngine(storage, mutations, FakeBackend()) + val durable = reopened.deadLetters().single() + assertEquals(dead.failure.detail, durable.failure.detail) + assertEquals(dead.failure.message, durable.failure.message) + assertEquals(dead.failure.occurredAtEpochMillis, durable.failure.occurredAtEpochMillis) + } + + @Test + fun projectionThrowableEmitsExactEphemeralCauseAndNormalizedDurableFailure() = runTest { + val exact = IllegalStateException("projection\tfailure\nprivate stack") + val storage = InMemoryMutationJournalStorage() + val mutations = ParkingMutations(projectionFailure = exact) + val backend = FakeBackend() + val engine = openParkingEngine(storage, mutations, backend) + val key = MutationsTestKey("projection-park") + val mutationId = engine.mutate(key, mutations.hostile, "ignored") + + engine.drain(key) + + val poisoned = engine.poisoned.replayCache.single() + assertEquals(mutationId, poisoned.mutationId) + assertSame(exact, poisoned.failure) + assertEquals(emptyList(), backend.receivedPushes) + assertEquals(emptyList(), engine.pending(key)) + val dead = engine.deadLetters().single() + assertEquals(MutationFailureKind.PROJECTION, dead.failure.kind) + assertTrue(dead.failure.detail.encodeToByteArray().size <= 128) + assertTrue(dead.failure.message.encodeToByteArray().size <= 1_024) + assertTrue(dead.failure.message.none(Char::isISOControl)) + assertFalse(dead.failure.message.contains("private stack")) + + val reopened = openParkingEngine(storage, mutations, FakeBackend()) + assertEquals(mutationId, reopened.deadLetters().single().mutationId) + assertTrue(reopened.poisoned.replayCache.isEmpty()) + } + + @Test + fun globalDrainContinuesPastEveryParkedIdentity() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = ParkingMutations() + seedParkingStorage( + storage, + ParkingSeed(1L, "codec-park", "codec-park", "missing-mutator", 1, byteArrayOf()), + ParkingSeed( + 2L, + "identity-park", + "identity-park", + mutations.append.id, + 1, + "+identity".encodeToByteArray(), + ), + ParkingSeed( + 3L, + "healthy-terminal", + "healthy-terminal", + mutations.append.id, + 1, + "+ok".encodeToByteArray(), + ), + ) + val backend = FakeBackend() + val engine = + openParkingEngine( + storage, + mutations, + backend, + resolver = MutationKeyResolver { identity -> + if (identity.canonicalId == "identity-park") { + throw IllegalStateException("identity cannot be reconstructed") + } + MutationsTestKeyResolver.resolve(identity) + }, + ) + + engine.drain() + + assertEquals(listOf("healthy-terminal"), backend.receivedPushes.map { it.identity.canonicalId }) + assertEquals(listOf("base+ok"), backend.pushedValues) + assertEquals( + setOf(MutationFailureKind.CODEC, MutationFailureKind.IDENTITY), + engine.deadLetters().map { it.failure.kind }.toSet(), + ) + assertEquals(2, engine.deadLetters().size) + assertEquals(emptyList(), engine.pendingWrites()) + assertEquals(StoredPhase.RETIRED, storedState(storage, "healthy-terminal").execution.phase) + } + + @Test + fun postAckFailureNeverCreatesDeadLetter() = runTest { + val errors = mutableListOf() + for (failureMode in ParkingPostAckFailureMode.entries) { + try { + assertPostAckFailureHasNoDeadLetter(failureMode) + } catch (failure: AssertionError) { + errors += "${failureMode.name}: ${failure.message}" + } + } + assertTrue(errors.isEmpty(), errors.joinToString(separator = "\n")) + } + + @Test + fun globalDrainContinuesPastRetryablePostAckFailure() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = ParkingMutations() + val backend = FakeBackend() + val blocked = + MutationsTestKey( + "global-post-ack-blocked", + StoreNamespace("global-post-ack-blocked-namespace"), + ) + val healthy = + MutationsTestKey( + "global-post-ack-healthy", + StoreNamespace("global-post-ack-healthy-namespace"), + ) + val handle = ScriptedParkingHandle() + val resolver = + MutationKeyResolver { identity -> + MutationsTestKey(identity.canonicalId, StoreNamespace(identity.namespace)) + } + val engine = + openParkingEngine( + storage = storage, + mutations = mutations, + backend = backend, + resolver = resolver, + handle = handle, + ) + val blockedId = engine.mutate(blocked, mutations.append, "+blocked") + val healthyId = engine.mutate(healthy, mutations.append, "+healthy") + handle.remainingApplyFailures[blocked.identity()] = 1 + assertNotNull(captureParkingFailure { engine.drain(blocked) }) + assertEquals(StoredPhase.ACKED, storedState(storage, blockedId).execution.phase) + val pushCountBeforeGlobal = backend.receivedPushes.size + handle.applyAttempts.clear() + handle.remainingApplyFailures[blocked.identity()] = 1 + engine.clearLiveKeyCache() + + val exposed = captureParkingFailure { engine.drain() } + val blockedState = storedState(storage, blockedId) + val healthyState = storedState(storage, healthyId) + val deadLetters = engine.deadLetters() + val errors = mutableListOf() + expectParking(errors, "global drain returns normally") { assertNull(exposed) } + expectParking(errors, "retryable post-ack owner stays ACKED") { + assertEquals(StoredPhase.ACKED, blockedState.execution.phase) + } + expectParking(errors, "different namespace completes") { + assertEquals(StoredPhase.RETIRED, healthyState.execution.phase) + } + expectParking(errors, "each namespace starts authority work once") { + assertEquals( + listOf(blocked.identity(), healthy.identity()), + backend.receivedPushes.map { push -> + KeyIdentity(push.identity.namespace, push.identity.canonicalId) + }, + ) + assertEquals(pushCountBeforeGlobal + 1, backend.receivedPushes.size) + assertEquals(listOf(blocked.identity(), healthy.identity()), handle.applyAttempts) + } + expectParking(errors, "post-ack failure never parks") { + assertTrue(deadLetters.isEmpty()) + } + assertTrue(errors.isEmpty(), errors.joinToString(separator = "\n")) + } + + @Test + fun parkingCancellationAfterCommit_stillPublishesOverlayRevisionAndRebasesSuffix() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = ParkingMutations() + val key = MutationsTestKey("cancelled-park") + seedParkingStorage( + storage, + ParkingSeed( + 1L, + key.canonicalId(), + "cancelled-park-head", + mutations.append.id, + 1, + "+parked".encodeToByteArray(), + ), + ParkingSeed( + 2L, + key.canonicalId(), + "cancelled-park-suffix", + mutations.append.id, + 1, + "+suffix".encodeToByteArray(), + ), + ) + val backend = FakeBackend() + val engine = + openParkingEngine( + storage, + mutations, + backend, + resolver = MutationKeyResolver { identity -> + if (identity.canonicalId == key.canonicalId()) { + throw IllegalStateException("scripted resolver park") + } + MutationsTestKeyResolver.resolve(identity) + }, + ) + val blocker = MutationsTestKey("parking-signal-blocker") + val blockerObserved = CompletableDeferred() + val parkedObserved = CompletableDeferred() + val collector = + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + engine.changes.collect { changed -> + when (changed.canonicalId()) { + blocker.canonicalId() -> blockerObserved.complete(changed.canonicalId()) + key.canonicalId() -> parkedObserved.complete(changed.canonicalId()) + } + } + } + + try { + engine.mutate(blocker, mutations.append, "+blocker") + assertEquals("base+parked+suffix", engine.overlay.apply(key, "base")) + + val cancelledDrain = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.drain() + } + assertFalse(cancelledDrain.isCompleted) + + assertEquals("cancelled-park-head", engine.deadLetters().single().mutationId) + assertEquals( + listOf("cancelled-park-suffix"), + engine.pending(key).map { pending -> pending.mutationId }, + ) + assertEquals("base+suffix", engine.overlay.apply(key, "base")) + assertEquals(emptyList(), backend.receivedPushes) + + cancelledDrain.cancel() + testScheduler.runCurrent() + cancelledDrain.join() + + assertTrue(cancelledDrain.isCancelled) + assertEquals(blocker.canonicalId(), blockerObserved.await()) + assertTrue( + parkedObserved.isCompleted, + "a committed park must retain its identity revision across cancellation", + ) + assertEquals(key.canonicalId(), parkedObserved.await()) + assertEquals("base+suffix", engine.overlay.apply(key, "base")) + } finally { + collector.cancelAndJoin() + } + } +} + +private const val PARKING_CLIENT_ID: String = "client-0" + +private class ParkingMutations( + projectionFailure: Throwable? = null, + staleKeys: Set = emptySet(), +) { + private val argsCodec = StrictVersionOneStringCodec() + lateinit var append: MutatorRef + lateinit var strictUpdate: MutatorRef + lateinit var hostile: MutatorRef + + val registry: MutatorRegistry = + mutatorRegistry { + append = + upsert( + id = "parking-append", + version = 1, + codec = argsCodec, + stales = + if (staleKeys.isEmpty()) { + noStales() + } else { + typedStales(keys = staleKeys) + }, + ) { base, suffix -> + MutationPresence.Present( + (base as? MutationPresence.Present)?.value.orEmpty() + suffix, + ) + } + strictUpdate = + update( + id = "parking-strict-update", + version = 1, + codec = argsCodec, + stales = noStales(), + ) { current, suffix -> current + suffix } + hostile = + mutator( + id = "parking-hostile", + version = 1, + codec = argsCodec, + stales = noStales(), + ) { base, _ -> + projectionFailure?.let { throw it } + base + } + } +} + +private class StrictVersionOneStringCodec : MutationCodec { + val decodeVersions: MutableList = mutableListOf() + + override fun encode(value: String): ByteArray = value.encodeToByteArray() + + override fun decode(version: Int, bytes: ByteArray): String { + decodeVersions += version + require(version == 1) { "Only codec version 1 is available; was $version." } + return bytes.decodeToString() + } +} + +private object ParkingNoopHandle : StoreWriteHandle { + override suspend fun apply(key: MutationsTestKey, value: String) = Unit + + override suspend fun markStale(key: MutationsTestKey) = Unit + + override suspend fun confirmFresh(key: MutationsTestKey, etag: String?) = Unit +} + +private class MapBackedParkingHandle( + private val values: MutableMap, +) : StoreWriteHandle { + override suspend fun apply(key: MutationsTestKey, value: String) { + values[key.canonicalId()] = value + } + + override suspend fun markStale(key: MutationsTestKey) = Unit + + override suspend fun confirmFresh(key: MutationsTestKey, etag: String?) = Unit +} + +private class ScriptedParkingHandle : StoreWriteHandle { + val remainingApplyFailures = mutableMapOf() + val remainingMarkStaleFailures = mutableMapOf() + val applyAttempts = mutableListOf() + val markStaleAttempts = mutableListOf() + + override suspend fun apply( + key: MutationsTestKey, + value: String, + ) { + val identity = key.identity() + applyAttempts += identity + val remaining = remainingApplyFailures[identity] ?: 0 + if (remaining > 0) { + remainingApplyFailures[identity] = remaining - 1 + throw IllegalStateException("scripted adoption failure") + } + } + + override suspend fun markStale(key: MutationsTestKey) { + val identity = key.identity() + markStaleAttempts += identity + val remaining = remainingMarkStaleFailures[identity] ?: 0 + if (remaining > 0) { + remainingMarkStaleFailures[identity] = remaining - 1 + throw IllegalStateException("scripted effect failure") + } + } + + override suspend fun confirmFresh( + key: MutationsTestKey, + etag: String?, + ) = Unit +} + +private fun openParkingEngine( + storage: MutationJournalStorage, + mutations: ParkingMutations, + backend: FakeBackend, + resolver: MutationKeyResolver = MutationsTestKeyResolver, + baseReader: suspend (MutationsTestKey) -> String? = { "base" }, + valueCodec: MutationCodec = StrictVersionOneStringCodec(), + wallClock: WallClock = TestWallClock(), + handle: StoreWriteHandle = ParkingNoopHandle, +): MutationEngine { + val journal = + StorageBackedMutationJournal( + storage = storage, + registrations = mutations.registry.registrations, + clientId = PARKING_CLIENT_ID, + hydrateOnFirstUse = true, + ) + return MutationEngine( + registry = mutations.registry, + server = backend, + journal = journal, + keyResolver = resolver, + valueCodecVersion = 1, + valueCodec = valueCodec, + baseReader = baseReader, + wallClock = wallClock, + clientId = PARKING_CLIENT_ID, + ).also { it.bind(handle) } +} + +private enum class ParkingPostAckFailureMode( + val expectedPhase: StoredPhase, +) { + ADOPTION(StoredPhase.ACKED), + EFFECT(StoredPhase.EFFECTS_PENDING), + PERSISTENCE(StoredPhase.EFFECTS_PENDING), +} + +private suspend fun assertPostAckFailureHasNoDeadLetter( + failureMode: ParkingPostAckFailureMode, +) { + val backing = InMemoryMutationJournalStorage() + val storage = FailPointJournalStorage(backing) + val key = MutationsTestKey("post-ack-${failureMode.name.lowercase()}") + val effectTarget = MutationsTestKey("post-ack-${failureMode.name.lowercase()}-effect") + val mutations = + ParkingMutations( + staleKeys = setOf(effectTarget).takeIf { + failureMode == ParkingPostAckFailureMode.EFFECT + }.orEmpty(), + ) + val backend = + FakeBackend().apply { + retireBehavior = { + MutationRetirementAck(confirmedThroughSequence = 0L) + } + } + val firstHandle = ScriptedParkingHandle() + configureParkingFailure(failureMode, key, effectTarget, storage, firstHandle) + val first = openParkingEngine(storage, mutations, backend, handle = firstHandle) + val mutationId = first.mutate(key, mutations.append, "+value") + + assertNotNull(captureParkingFailure { first.drain(key) }) + assertEquals(failureMode.expectedPhase, storedState(backing, mutationId).execution.phase) + assertNull(storedState(backing, mutationId).execution.activeFailureId) + assertTrue(first.deadLetters().isEmpty()) + val pushCount = backend.receivedPushes.size + + val reopenedHandle = ScriptedParkingHandle() + val reopened = openParkingEngine(storage, mutations, backend, handle = reopenedHandle) + assertEquals(failureMode.expectedPhase, storedState(backing, mutationId).execution.phase) + assertTrue(reopened.deadLetters().isEmpty()) + assertNull(captureParkingFailure { reopened.drain(key) }) + assertEquals(StoredPhase.RETIRED, storedState(backing, mutationId).execution.phase) + assertEquals(pushCount, backend.receivedPushes.size) + assertTrue(reopened.deadLetters().isEmpty()) +} + +private fun configureParkingFailure( + failureMode: ParkingPostAckFailureMode, + key: MutationsTestKey, + effectTarget: MutationsTestKey, + storage: FailPointJournalStorage, + handle: ScriptedParkingHandle, +) { + when (failureMode) { + ParkingPostAckFailureMode.ADOPTION -> + handle.remainingApplyFailures[key.identity()] = 1 + ParkingPostAckFailureMode.EFFECT -> + handle.remainingMarkStaleFailures[effectTarget.identity()] = 1 + ParkingPostAckFailureMode.PERSISTENCE -> + storage.armFailTransaction(JournalFailPointBoundary.FINALIZATION) + } +} + +private suspend fun captureParkingFailure(block: suspend () -> Unit): Throwable? = + try { + block() + null + } catch (failure: Throwable) { + if (failure is CancellationException) throw failure + failure + } + +private fun expectParking( + errors: MutableList, + label: String, + assertion: () -> Unit, +) { + try { + assertion() + } catch (failure: AssertionError) { + errors += "$label: ${failure.message}" + } +} + +private data class ParkingSeed( + val sequence: Long, + val canonicalId: String, + val mutationId: String, + val mutatorId: String, + val mutatorVersion: Int, + val argsBlob: ByteArray, + val phase: StoredPhase = StoredPhase.UNPREPARED, + val valueCodecVersion: Int = 1, +) + +private suspend fun seedParkingStorage( + storage: InMemoryMutationJournalStorage, + vararg seeds: ParkingSeed, +) { + require(seeds.isNotEmpty()) + val ordered = seeds.sortedBy { it.sequence } + storage.transaction { transaction -> + transaction.insertClient( + MutationClientRecord(1, PARKING_CLIENT_ID, 0L, 0L, 0L, 100L), + ) + transaction.advanceClient( + MutationClientRecord(1, PARKING_CLIENT_ID, ordered.last().sequence, 0L, 0L, 100L), + ) + ordered.forEach { seed -> + transaction.insertIntent( + recordVersion = 1, + clientId = PARKING_CLIENT_ID, + clientSequence = seed.sequence, + mutationId = seed.mutationId, + namespace = "mutations", + canonicalId = seed.canonicalId, + mutatorId = seed.mutatorId, + mutatorVersion = seed.mutatorVersion, + argsBlob = seed.argsBlob, + idempotencyRoot = "$PARKING_CLIENT_ID:${seed.sequence}", + createdAt = 100L + seed.sequence, + ) + val unprepared = + MutationExecutionRecord( + PARKING_CLIENT_ID, + seed.sequence, + StoredPhase.UNPREPARED, + 0, + 0, + null, + null, + null, + ) + transaction.insertExecution(unprepared) + if (seed.phase != StoredPhase.UNPREPARED) { + transaction.insertAttempt(parkingAttempt(seed)) + val ready = + MutationExecutionRecord( + PARKING_CLIENT_ID, + seed.sequence, + StoredPhase.READY, + 1, + 0, + null, + null, + null, + ) + transaction.advanceExecution(ready) + if (seed.phase == StoredPhase.INFLIGHT) { + transaction.advanceExecution( + MutationExecutionRecord( + PARKING_CLIENT_ID, + seed.sequence, + StoredPhase.INFLIGHT, + 1, + 0, + null, + null, + null, + ), + ) + } else { + require(seed.phase == StoredPhase.READY) + } + } + } + } +} + +private fun parkingAttempt(seed: ParkingSeed): MutationAttemptRecord = + MutationAttemptRecord( + clientId = PARKING_CLIENT_ID, + clientSequence = seed.sequence, + generation = 1, + effectiveNamespace = "mutations", + effectiveCanonicalId = seed.canonicalId, + valueCodecVersion = seed.valueCodecVersion, + basePresence = MutationPresenceState.PRESENT, + baseBlob = "base".encodeToByteArray(), + minePresence = MutationPresenceState.PRESENT, + mineBlob = "mine-${seed.sequence}".encodeToByteArray(), + preconditionMetaPresent = false, + preconditionWrittenAt = null, + preconditionEtag = null, + advertisedRetiredThroughSequence = 0L, + generationIdempotencyKey = "$PARKING_CLIENT_ID:${seed.sequence}:g1", + preparedAt = 200L + seed.sequence, + conflictMetaPresent = null, + conflictWrittenAt = null, + conflictEtag = null, + conflictReceivedAt = null, + ) + +private data class StoredState( + val execution: MutationExecutionRecord, + val attempt: MutationAttemptRecord?, + val activeFailure: MutationFailureRecord?, +) + +private suspend fun storedState( + storage: InMemoryMutationJournalStorage, + mutationId: String, +): StoredState = + storage.transaction { transaction -> + val intent = transaction.intents(PARKING_CLIENT_ID).single { it.mutationId == mutationId } + val execution = + transaction.executions(PARKING_CLIENT_ID).single { + it.clientSequence == intent.clientSequence + } + StoredState( + execution = execution, + attempt = + transaction.attempts(PARKING_CLIENT_ID).singleOrNull { + it.clientSequence == intent.clientSequence && + it.generation == execution.currentGeneration + }, + activeFailure = + execution.activeFailureId?.let { failureId -> + transaction.failures(PARKING_CLIENT_ID).single { it.failureId == failureId } + }, + ) + } + +private data class ParkingAttemptSnapshot( + val generation: Int, + val effectiveNamespace: String, + val effectiveCanonicalId: String, + val valueCodecVersion: Int, + val basePresence: MutationPresenceState, + val baseBlob: List?, + val minePresence: MutationPresenceState, + val mineBlob: List?, + val advertisedRetiredThroughSequence: Long, + val generationIdempotencyKey: String, + val preparedAt: Long, +) + +private fun MutationAttemptRecord.snapshot(): ParkingAttemptSnapshot = + ParkingAttemptSnapshot( + generation, + effectiveNamespace, + effectiveCanonicalId, + valueCodecVersion, + basePresence, + baseBlob?.toList(), + minePresence, + mineBlob?.toList(), + advertisedRetiredThroughSequence, + generationIdempotencyKey, + preparedAt, + ) + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = 25.seconds, testBody = testBody) diff --git a/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationDrainResumabilityMatrixTest.kt b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationDrainResumabilityMatrixTest.kt new file mode 100644 index 000000000..c95e93233 --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationDrainResumabilityMatrixTest.kt @@ -0,0 +1,1096 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, +) + +package org.mobilenativefoundation.store6.mutations + +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.StoreException +import org.mobilenativefoundation.store6.core.StoreMeta +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.seam.StoreResults +import org.mobilenativefoundation.store6.core.seam.StoreWriteHandle +import org.mobilenativefoundation.store6.mutations.storage.InMemoryMutationJournalStorage +import org.mobilenativefoundation.store6.mutations.storage.MutationAttemptRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationClientRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionPhase as StoredPhase +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationFailureRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationJournalStorage +import org.mobilenativefoundation.store6.testing.TestWallClock + +class MutationDrainResumabilityMatrixTest { + @Test + fun killAtEveryAwaitPoint_thenRestartDrain_completesWithoutLossOrDoublePush() = runTest { + assertEquals(38, MATRIX_CELLS.size) + MATRIX_CELLS.forEachIndexed { cellIndex, cell -> + val context = + "masterSeed=$MATRIX_MASTER_SEED cellIndex=$cellIndex cell=${cell.name} " + + "transition=${cell.transition} boundary=${cell.boundary} " + + "occurrence=${cell.occurrence}" + try { + assertMatrixCell(cell) + } catch (failure: Throwable) { + throw AssertionError("$context\n${failure.stackTraceToString()}") + } + } + } +} + +private enum class MatrixScenario { + SUCCESS, + TRANSPORT_ACK_LOST, + CONFLICT_RECEIPT, + RETRY_GENERATION, + SERVER_WINS, + CHECKPOINT_OR_PRUNE, + PARK_IDENTITY, + PARK_CODEC, + PARK_SELECTOR, + PARK_MERGE, + PARK_UNCHANGED_BOUND, + PARK_PROTOCOL, +} + +private enum class MatrixKillMode { + BEFORE_COMMIT, + AFTER_COMMIT, + TRANSPORT, +} + +private enum class MatrixTransition { + PREPARATION, + INFLIGHT, + CONFLICT_RECEIPT, + RETRY_GENERATION, + SERVER_WINS, + PARK, +} + +private data class MatrixCell( + val name: String, + val scenario: MatrixScenario, + val mode: MatrixKillMode, + val transition: MatrixTransition? = null, + val boundary: JournalFailPoint? = null, + val occurrence: Int = 1, +) + +private val MATRIX_CELLS = + listOf( + MatrixCell( + "preparation-before", + MatrixScenario.SUCCESS, + MatrixKillMode.BEFORE_COMMIT, + transition = MatrixTransition.PREPARATION, + ), + MatrixCell( + "preparation-after", + MatrixScenario.SUCCESS, + MatrixKillMode.AFTER_COMMIT, + transition = MatrixTransition.PREPARATION, + ), + MatrixCell( + "inflight-before", + MatrixScenario.SUCCESS, + MatrixKillMode.BEFORE_COMMIT, + transition = MatrixTransition.INFLIGHT, + ), + MatrixCell( + "inflight-after", + MatrixScenario.SUCCESS, + MatrixKillMode.AFTER_COMMIT, + transition = MatrixTransition.INFLIGHT, + ), + MatrixCell( + "transport-acknowledgement-lost", + MatrixScenario.TRANSPORT_ACK_LOST, + MatrixKillMode.TRANSPORT, + ), + MatrixCell( + "conflict-receipt-before", + MatrixScenario.CONFLICT_RECEIPT, + MatrixKillMode.BEFORE_COMMIT, + transition = MatrixTransition.CONFLICT_RECEIPT, + ), + MatrixCell( + "conflict-receipt-after", + MatrixScenario.CONFLICT_RECEIPT, + MatrixKillMode.AFTER_COMMIT, + transition = MatrixTransition.CONFLICT_RECEIPT, + ), + MatrixCell( + "generation-g-plus-one-before", + MatrixScenario.RETRY_GENERATION, + MatrixKillMode.BEFORE_COMMIT, + transition = MatrixTransition.RETRY_GENERATION, + ), + MatrixCell( + "generation-g-plus-one-after", + MatrixScenario.RETRY_GENERATION, + MatrixKillMode.AFTER_COMMIT, + transition = MatrixTransition.RETRY_GENERATION, + ), + MatrixCell( + "server-wins-before", + MatrixScenario.SERVER_WINS, + MatrixKillMode.BEFORE_COMMIT, + transition = MatrixTransition.SERVER_WINS, + ), + MatrixCell( + "server-wins-after", + MatrixScenario.SERVER_WINS, + MatrixKillMode.AFTER_COMMIT, + transition = MatrixTransition.SERVER_WINS, + ), + MatrixCell( + "ack-receipt-before", + MatrixScenario.SUCCESS, + MatrixKillMode.BEFORE_COMMIT, + boundary = JournalFailPointBoundary.ACK_RECEIPT, + ), + MatrixCell( + "ack-receipt-after", + MatrixScenario.SUCCESS, + MatrixKillMode.AFTER_COMMIT, + boundary = JournalFailPointBoundary.ACK_RECEIPT, + ), + MatrixCell( + "adoption-before", + MatrixScenario.SUCCESS, + MatrixKillMode.BEFORE_COMMIT, + boundary = JournalFailPointBoundary.ADOPTION_ADVANCE, + ), + MatrixCell( + "adoption-after", + MatrixScenario.SUCCESS, + MatrixKillMode.AFTER_COMMIT, + boundary = JournalFailPointBoundary.ADOPTION_ADVANCE, + ), + MatrixCell( + "effect-zero-before", + MatrixScenario.SUCCESS, + MatrixKillMode.BEFORE_COMMIT, + boundary = JournalFailPointBoundary.EFFECT_MARKER, + occurrence = 1, + ), + MatrixCell( + "effect-zero-after", + MatrixScenario.SUCCESS, + MatrixKillMode.AFTER_COMMIT, + boundary = JournalFailPointBoundary.EFFECT_MARKER, + occurrence = 1, + ), + MatrixCell( + "effect-one-before", + MatrixScenario.SUCCESS, + MatrixKillMode.BEFORE_COMMIT, + boundary = JournalFailPointBoundary.EFFECT_MARKER, + occurrence = 2, + ), + MatrixCell( + "effect-one-after", + MatrixScenario.SUCCESS, + MatrixKillMode.AFTER_COMMIT, + boundary = JournalFailPointBoundary.EFFECT_MARKER, + occurrence = 2, + ), + MatrixCell( + "finalization-before", + MatrixScenario.SUCCESS, + MatrixKillMode.BEFORE_COMMIT, + boundary = JournalFailPointBoundary.FINALIZATION, + ), + MatrixCell( + "finalization-after", + MatrixScenario.SUCCESS, + MatrixKillMode.AFTER_COMMIT, + boundary = JournalFailPointBoundary.FINALIZATION, + ), + MatrixCell( + "checkpoint-confirmation-before", + MatrixScenario.CHECKPOINT_OR_PRUNE, + MatrixKillMode.BEFORE_COMMIT, + boundary = JournalFailPointBoundary.CHECKPOINT_CONFIRMATION, + ), + MatrixCell( + "checkpoint-confirmation-after", + MatrixScenario.CHECKPOINT_OR_PRUNE, + MatrixKillMode.AFTER_COMMIT, + boundary = JournalFailPointBoundary.CHECKPOINT_CONFIRMATION, + ), + MatrixCell( + "before-prune", + MatrixScenario.CHECKPOINT_OR_PRUNE, + MatrixKillMode.BEFORE_COMMIT, + boundary = JournalFailPointBoundary.BEFORE_PRUNE, + ), + MatrixCell( + "before-prune-commit", + MatrixScenario.CHECKPOINT_OR_PRUNE, + MatrixKillMode.BEFORE_COMMIT, + boundary = JournalFailPointBoundary.PRUNE_COMMIT, + ), + MatrixCell( + "after-prune-commit", + MatrixScenario.CHECKPOINT_OR_PRUNE, + MatrixKillMode.AFTER_COMMIT, + boundary = JournalFailPointBoundary.PRUNE_COMMIT, + ), + parkCell("identity-park-before", MatrixScenario.PARK_IDENTITY, MatrixKillMode.BEFORE_COMMIT), + parkCell("identity-park-after", MatrixScenario.PARK_IDENTITY, MatrixKillMode.AFTER_COMMIT), + parkCell("codec-park-before", MatrixScenario.PARK_CODEC, MatrixKillMode.BEFORE_COMMIT), + parkCell("codec-park-after", MatrixScenario.PARK_CODEC, MatrixKillMode.AFTER_COMMIT), + parkCell("selector-park-before", MatrixScenario.PARK_SELECTOR, MatrixKillMode.BEFORE_COMMIT), + parkCell("selector-park-after", MatrixScenario.PARK_SELECTOR, MatrixKillMode.AFTER_COMMIT), + parkCell("merge-park-before", MatrixScenario.PARK_MERGE, MatrixKillMode.BEFORE_COMMIT), + parkCell("merge-park-after", MatrixScenario.PARK_MERGE, MatrixKillMode.AFTER_COMMIT), + parkCell( + "unchanged-bound-park-before", + MatrixScenario.PARK_UNCHANGED_BOUND, + MatrixKillMode.BEFORE_COMMIT, + ), + parkCell( + "unchanged-bound-park-after", + MatrixScenario.PARK_UNCHANGED_BOUND, + MatrixKillMode.AFTER_COMMIT, + ), + parkCell("protocol-park-before", MatrixScenario.PARK_PROTOCOL, MatrixKillMode.BEFORE_COMMIT), + parkCell("protocol-park-after", MatrixScenario.PARK_PROTOCOL, MatrixKillMode.AFTER_COMMIT), + ) + +private fun parkCell( + name: String, + scenario: MatrixScenario, + mode: MatrixKillMode, +): MatrixCell = MatrixCell(name, scenario, mode, transition = MatrixTransition.PARK) + +private suspend fun assertMatrixCell(cell: MatrixCell) { + when (cell.scenario) { + MatrixScenario.SUCCESS -> assertSuccessfulLifecycleCell(cell) + MatrixScenario.TRANSPORT_ACK_LOST -> assertTransportAcknowledgementLostCell() + MatrixScenario.CONFLICT_RECEIPT, + MatrixScenario.RETRY_GENERATION, + -> assertConflictRetryCell(cell) + MatrixScenario.SERVER_WINS -> assertServerWinsCell(cell) + MatrixScenario.CHECKPOINT_OR_PRUNE -> assertCheckpointOrPruneCell(cell) + MatrixScenario.PARK_IDENTITY -> assertIdentityParkCell(cell) + MatrixScenario.PARK_CODEC -> assertCodecParkCell(cell) + MatrixScenario.PARK_SELECTOR -> assertSelectorParkCell(cell) + MatrixScenario.PARK_MERGE -> assertMergeParkCell(cell) + MatrixScenario.PARK_UNCHANGED_BOUND -> assertUnchangedBoundParkCell(cell) + MatrixScenario.PARK_PROTOCOL -> assertProtocolParkCell(cell) + } +} + +private suspend fun assertSuccessfulLifecycleCell(cell: MatrixCell) { + val raw = InMemoryMutationJournalStorage() + val storage = FailPointJournalStorage(raw) + val mutations = MatrixMutations(effectful = true) + val backend = retainingMatrixBackend() + val state = MatrixState() + val clock = TestWallClock(10_000L) + val key = MutationsTestKey(cell.name, MATRIX_NAMESPACE) + val first = openMatrixEngine(storage, mutations.registry, backend, state, clock) + val mutationId = first.mutate(key, mutations.set, "value-${cell.name}") + storage.arm(cell) + + assertJournalDeath(cell, storage) { first.drain(key) } + + val reopened = + openMatrixEngine( + FailPointJournalStorage(raw), + mutations.registry, + backend, + state, + clock, + ) + reopened.drain(key) + + assertRetired(raw, mutationId, reopened) + assertEquals(1, backend.effectivePushApplications.size) + assertEveryEffectivePushAppliedOnce(backend) + val expectedReceivedPushes = + if ( + cell.boundary == JournalFailPointBoundary.ACK_RECEIPT && + cell.mode == MatrixKillMode.BEFORE_COMMIT + ) { + 2 + } else { + 1 + } + assertPushHighWater(backend, mutationId, expectedReceivedPushes, expectedDistinctKeys = 1) + assertTrue(MATRIX_EFFECT_KEY.identity() in state.staleKeys) + assertTrue(state.staleNamespaces.any { it.value == MATRIX_EFFECT_NAMESPACE.value }) +} + +private suspend fun assertTransportAcknowledgementLostCell() { + val raw = InMemoryMutationJournalStorage() + val mutations = MatrixMutations(effectful = false) + val backend = retainingMatrixBackend() + val state = MatrixState() + val clock = TestWallClock(20_000L) + val key = MutationsTestKey("transport-ack-lost", MATRIX_NAMESPACE) + val first = + openMatrixEngine( + FailPointJournalStorage(raw), + mutations.registry, + MatrixAckLossServer(backend), + state, + clock, + ) + val mutationId = first.mutate(key, mutations.set, "transport-value") + + assertIs(captureMatrixFailure { first.drain(key) }) + assertEquals(StoredPhase.INFLIGHT, raw.matrixState(mutationId).execution.phase) + + val reopened = + openMatrixEngine( + FailPointJournalStorage(raw), + mutations.registry, + backend, + state, + clock, + ) + reopened.drain(key) + + assertRetired(raw, mutationId, reopened) + assertPushHighWater(backend, mutationId, expectedReceipts = 2, expectedDistinctKeys = 1) + assertEquals(1, backend.effectivePushApplications.size) + assertEveryEffectivePushAppliedOnce(backend) +} + +private suspend fun assertConflictRetryCell(cell: MatrixCell) { + val raw = InMemoryMutationJournalStorage() + val storage = FailPointJournalStorage(raw) + val mutations = MatrixMutations(effectful = false) + val backend = retainingMatrixBackend() + backend.pushBehavior = { _, value -> + if (backend.receivedPushes.last().generation == 1) { + throw matrixConflict(MatrixMeta(1L, "same-conflict")) + } + MutationPresentAck(value, "retry-ack", null) + } + val state = MatrixState() + val clock = TestWallClock(30_000L) + val key = MutationsTestKey(cell.name, MATRIX_NAMESPACE) + val policy = + MatrixConflictPolicy( + merge = { _, mine, _ -> MutationConflictResolution.Retry(mine) }, + ) + val first = openMatrixEngine(storage, mutations.registry, backend, state, clock, conflicts = policy) + val mutationId = first.mutate(key, mutations.set, "conflict-value") + + if (cell.scenario == MatrixScenario.RETRY_GENERATION) { + first.drain(key) + assertEquals(StoredPhase.REFRESH_REQUIRED, raw.matrixState(mutationId).execution.phase) + clock.setEpochMillis(32_000L) + } + storage.arm(cell) + assertJournalDeath(cell, storage) { first.drain(key) } + + if ( + cell.scenario == MatrixScenario.CONFLICT_RECEIPT && + cell.mode == MatrixKillMode.AFTER_COMMIT + ) { + clock.setEpochMillis(32_000L) + } + val reopened = + openMatrixEngine( + FailPointJournalStorage(raw), + mutations.registry, + backend, + state, + clock, + conflicts = policy, + ) + reopened.drain(key) + if ( + cell.scenario == MatrixScenario.CONFLICT_RECEIPT && + cell.mode == MatrixKillMode.BEFORE_COMMIT + ) { + clock.setEpochMillis(34_000L) + reopened.drain(key) + } + + val completed = assertRetired(raw, mutationId, reopened) + assertEquals(listOf(1, 2), completed.attempts.map { it.generation }) + assertEquals(1, backend.effectivePushApplications.size) + assertEveryEffectivePushAppliedOnce(backend) + val receivedByGeneration = backend.receivedPushes.groupingBy { it.generation }.eachCount() + val generationOneReceipts = + if ( + cell.scenario == MatrixScenario.CONFLICT_RECEIPT && + cell.mode == MatrixKillMode.BEFORE_COMMIT + ) { + 2 + } else { + 1 + } + assertEquals( + generationOneReceipts, + receivedByGeneration.getValue(1), + ) + assertEquals(1, receivedByGeneration.getValue(2)) + assertPushHighWater( + backend, + mutationId, + expectedReceipts = generationOneReceipts + 1, + expectedDistinctKeys = 2, + ) +} + +private suspend fun assertServerWinsCell(cell: MatrixCell) { + val raw = InMemoryMutationJournalStorage() + val storage = FailPointJournalStorage(raw) + val mutations = MatrixMutations(effectful = false) + val backend = retainingMatrixBackend() + backend.pushBehavior = { _, _ -> throw matrixConflict(MatrixMeta(2L, "server-wins")) } + val state = MatrixState() + val clock = TestWallClock(40_000L) + val key = MutationsTestKey(cell.name, MATRIX_NAMESPACE) + val policy = + MatrixConflictPolicy( + merge = { _, _, _ -> MutationConflictResolution.ServerWins }, + ) + val first = openMatrixEngine(storage, mutations.registry, backend, state, clock, conflicts = policy) + val mutationId = first.mutate(key, mutations.set, "server-wins-value") + first.drain(key) + assertEquals(StoredPhase.REFRESH_REQUIRED, raw.matrixState(mutationId).execution.phase) + clock.setEpochMillis(42_000L) + storage.arm(cell) + + assertJournalDeath(cell, storage) { first.drain(key) } + + val reopened = + openMatrixEngine( + FailPointJournalStorage(raw), + mutations.registry, + backend, + state, + clock, + conflicts = policy, + ) + reopened.drain(key) + + assertRetired(raw, mutationId, reopened) + assertPushHighWater(backend, mutationId, expectedReceipts = 1, expectedDistinctKeys = 1) + assertTrue(backend.effectivePushApplications.isEmpty()) +} + +private suspend fun assertCheckpointOrPruneCell(cell: MatrixCell) { + val raw = InMemoryMutationJournalStorage() + val storage = FailPointJournalStorage(raw) + val mutations = MatrixMutations(effectful = false) + val backend = FakeBackend().apply { dedupingPushBehavior = true } + val state = MatrixState() + val clock = TestWallClock(50_000L) + val key = MutationsTestKey(cell.name, MATRIX_NAMESPACE) + val first = openMatrixEngine(storage, mutations.registry, backend, state, clock) + val mutationId = first.mutate(key, mutations.set, "checkpoint-value") + val sequence = raw.sequenceOf(mutationId) + storage.arm(cell) + + assertJournalDeath(cell, storage) { first.drain(key) } + + val reopened = + openMatrixEngine( + FailPointJournalStorage(raw), + mutations.registry, + backend, + state, + clock, + ) + reopened.drain(key) + + val persisted = raw.matrixClientState() + assertEquals(sequence, persisted.client.serverConfirmedRetiredThroughSequence) + assertTrue(persisted.executionSequences.none { it == sequence }) + assertTrue(persisted.intentSequences.none { it == sequence }) + assertTrue(reopened.pendingWrites().none { it.mutationId == mutationId }) + assertEquals(1, backend.effectivePushApplications.size) + assertEveryEffectivePushAppliedOnce(backend) + assertPushHighWater(backend, mutationId, expectedReceipts = 1, expectedDistinctKeys = 1) + assertEquals( + if ( + cell.boundary == JournalFailPointBoundary.CHECKPOINT_CONFIRMATION && + cell.mode == MatrixKillMode.BEFORE_COMMIT + ) { + 2 + } else { + 1 + }, + backend.retirementRequests.size, + ) +} + +private suspend fun assertIdentityParkCell(cell: MatrixCell) { + val raw = InMemoryMutationJournalStorage() + val mutations = MatrixMutations(effectful = false) + val backend = retainingMatrixBackend() + val state = MatrixState() + val clock = TestWallClock(60_000L) + val key = MutationsTestKey(cell.name, MATRIX_NAMESPACE) + val setup = + openMatrixEngine( + FailPointJournalStorage(raw), + mutations.registry, + backend, + state, + clock, + ) + val mutationId = setup.mutate(key, mutations.set, "identity-value") + val failingResolver = + MutationKeyResolver { + throw IllegalStateException("matrix resolver unavailable") + } + val storage = FailPointJournalStorage(raw) + val first = + openMatrixEngine( + storage, + mutations.registry, + backend, + state, + clock, + resolver = failingResolver, + ) + storage.arm(cell) + + assertJournalDeath(cell, storage) { first.drain() } + + val reopened = + openMatrixEngine( + FailPointJournalStorage(raw), + mutations.registry, + backend, + state, + clock, + resolver = failingResolver, + ) + reopened.drain() + assertParked(raw, mutationId, reopened, MutationFailureKind.IDENTITY) + assertTrue(backend.receivedPushes.isEmpty()) +} + +private suspend fun assertCodecParkCell(cell: MatrixCell) { + val raw = InMemoryMutationJournalStorage() + val mutations = MatrixMutations(effectful = false) + val missingRegistry: MutatorRegistry = mutatorRegistry { } + val backend = retainingMatrixBackend() + val state = MatrixState() + val clock = TestWallClock(70_000L) + val key = MutationsTestKey(cell.name, MATRIX_NAMESPACE) + val setup = + openMatrixEngine( + FailPointJournalStorage(raw), + mutations.registry, + backend, + state, + clock, + ) + val mutationId = setup.mutate(key, mutations.set, "codec-value") + val storage = FailPointJournalStorage(raw) + val first = openMatrixEngine(storage, missingRegistry, backend, state, clock) + storage.arm(cell) + + assertJournalDeath(cell, storage) { first.drain() } + + val reopened = + openMatrixEngine( + FailPointJournalStorage(raw), + missingRegistry, + backend, + state, + clock, + ) + reopened.drain() + assertParked(raw, mutationId, reopened, MutationFailureKind.CODEC) + assertTrue(backend.receivedPushes.isEmpty()) +} + +private suspend fun assertSelectorParkCell(cell: MatrixCell) { + val raw = InMemoryMutationJournalStorage() + val storage = FailPointJournalStorage(raw) + val mutations = MatrixMutations(effectful = false) + val backend = retainingMatrixBackend() + val state = MatrixState() + val clock = TestWallClock(80_000L) + val key = MutationsTestKey(cell.name, MATRIX_NAMESPACE) + val policy = + MatrixConflictPolicy( + precondition = { throw IllegalStateException("matrix selector failed") }, + ) + val first = openMatrixEngine(storage, mutations.registry, backend, state, clock, conflicts = policy) + val mutationId = first.mutate(key, mutations.set, "selector-value") + storage.arm(cell) + + assertJournalDeath(cell, storage) { first.drain(key) } + + val reopened = + openMatrixEngine( + FailPointJournalStorage(raw), + mutations.registry, + backend, + state, + clock, + conflicts = policy, + ) + reopened.drain(key) + assertParked(raw, mutationId, reopened, MutationFailureKind.CONFLICT) + assertTrue(backend.receivedPushes.isEmpty()) +} + +private suspend fun assertMergeParkCell(cell: MatrixCell) { + val raw = InMemoryMutationJournalStorage() + val storage = FailPointJournalStorage(raw) + val mutations = MatrixMutations(effectful = false) + val backend = retainingMatrixBackend() + backend.pushBehavior = { _, _ -> throw matrixConflict(MatrixMeta(3L, "merge")) } + val state = MatrixState() + val clock = TestWallClock(90_000L) + val key = MutationsTestKey(cell.name, MATRIX_NAMESPACE) + val policy = + MatrixConflictPolicy( + merge = { _, _, _ -> throw IllegalStateException("matrix merge failed") }, + ) + val first = openMatrixEngine(storage, mutations.registry, backend, state, clock, conflicts = policy) + val mutationId = first.mutate(key, mutations.set, "merge-value") + first.drain(key) + clock.setEpochMillis(92_000L) + storage.arm(cell) + + assertJournalDeath(cell, storage) { first.drain(key) } + + val reopened = + openMatrixEngine( + FailPointJournalStorage(raw), + mutations.registry, + backend, + state, + clock, + conflicts = policy, + ) + reopened.drain(key) + assertParked(raw, mutationId, reopened, MutationFailureKind.CONFLICT) + assertPushHighWater(backend, mutationId, expectedReceipts = 1, expectedDistinctKeys = 1) + assertTrue(backend.effectivePushApplications.isEmpty()) +} + +private suspend fun assertUnchangedBoundParkCell(cell: MatrixCell) { + val raw = InMemoryMutationJournalStorage() + val storage = FailPointJournalStorage(raw) + val mutations = MatrixMutations(effectful = false) + val backend = retainingMatrixBackend() + backend.pushBehavior = { _, _ -> throw matrixConflict(MatrixMeta(4L, "unchanged")) } + val state = MatrixState() + val clock = TestWallClock(100_000L) + val key = MutationsTestKey(cell.name, MATRIX_NAMESPACE) + val policy = + MatrixConflictPolicy( + merge = { _, mine, _ -> MutationConflictResolution.Retry(mine) }, + ) + val first = openMatrixEngine(storage, mutations.registry, backend, state, clock, conflicts = policy) + val mutationId = first.mutate(key, mutations.set, "unchanged-value") + first.drain(key) + clock.setEpochMillis(102_000L) + first.drain(key) + clock.setEpochMillis(104_000L) + storage.arm(cell) + + assertJournalDeath(cell, storage) { first.drain(key) } + + val reopened = + openMatrixEngine( + FailPointJournalStorage(raw), + mutations.registry, + backend, + state, + clock, + conflicts = policy, + ) + reopened.drain(key) + val parked = assertParked(raw, mutationId, reopened, MutationFailureKind.CONFLICT) + assertEquals(listOf(1, 2, 3), parked.attempts.map { it.generation }) + assertPushHighWater( + backend, + mutationId, + expectedReceipts = if (cell.mode == MatrixKillMode.BEFORE_COMMIT) 4 else 3, + expectedDistinctKeys = 3, + ) + assertTrue(backend.effectivePushApplications.isEmpty()) +} + +private suspend fun assertProtocolParkCell(cell: MatrixCell) { + val raw = InMemoryMutationJournalStorage() + val storage = FailPointJournalStorage(raw) + val mutations = MatrixMutations(effectful = false) + val backend = retainingMatrixBackend() + backend.pushBehavior = { key, value -> + MutationPresentAck( + authoritative = value, + etag = "protocol-$value", + canonicalKey = + when { + key.canonicalId() == "matrix-temp" -> MutationsTestKey("matrix-real", MATRIX_NAMESPACE) + value == "cycle-head" -> MutationsTestKey("matrix-temp", MATRIX_NAMESPACE) + else -> null + }, + ) + } + val state = MatrixState() + val clock = TestWallClock(110_000L) + val source = MutationsTestKey("matrix-temp", MATRIX_NAMESPACE) + val target = MutationsTestKey("matrix-real", MATRIX_NAMESPACE) + val first = openMatrixEngine(storage, mutations.registry, backend, state, clock) + first.mutate(source, mutations.set, "cycle-seed") + first.drain(source) + val mutationId = first.mutate(target, mutations.set, "cycle-head") + storage.arm(cell) + + assertJournalDeath(cell, storage) { first.drain(target) } + + val reopened = + openMatrixEngine( + FailPointJournalStorage(raw), + mutations.registry, + backend, + state, + clock, + ) + reopened.drain(target) + assertParked(raw, mutationId, reopened, MutationFailureKind.PROTOCOL) + assertPushHighWater( + backend, + mutationId, + expectedReceipts = if (cell.mode == MatrixKillMode.BEFORE_COMMIT) 2 else 1, + expectedDistinctKeys = 1, + expectedTotalReceipts = if (cell.mode == MatrixKillMode.BEFORE_COMMIT) 3 else 2, + ) + assertEquals(2, backend.effectivePushApplications.size) + assertEveryEffectivePushAppliedOnce(backend) +} + +private fun FailPointJournalStorage.arm(cell: MatrixCell) { + check(cell.mode != MatrixKillMode.TRANSPORT) + val predicate = cell.transition?.let(::matrixTransitionPredicate) + check((predicate == null) != (cell.boundary == null)) { + "Every journal cell must select exactly one transition or boundary." + } + when (cell.mode) { + MatrixKillMode.BEFORE_COMMIT -> + if (predicate != null) { + armKillBeforeCommit(predicate, cell.occurrence) + } else { + armKillBeforeCommit(checkNotNull(cell.boundary), cell.occurrence) + } + MatrixKillMode.AFTER_COMMIT -> + if (predicate != null) { + armKillAfterCommit(predicate, cell.occurrence) + } else { + armKillAfterCommit(checkNotNull(cell.boundary), cell.occurrence) + } + MatrixKillMode.TRANSPORT -> error("Transport acknowledgement loss has no journal arm.") + } +} + +private fun matrixTransitionPredicate( + transition: MatrixTransition, +): (MutationExecutionRecord, MutationExecutionRecord) -> Boolean = { before, after -> + when (transition) { + MatrixTransition.PREPARATION -> + before.phase == StoredPhase.UNPREPARED && + after.phase == StoredPhase.READY && + after.currentGeneration == 1 + MatrixTransition.INFLIGHT -> + before.phase == StoredPhase.READY && after.phase == StoredPhase.INFLIGHT + MatrixTransition.CONFLICT_RECEIPT -> + before.phase == StoredPhase.INFLIGHT && after.phase == StoredPhase.REFRESH_REQUIRED + MatrixTransition.RETRY_GENERATION -> + before.phase == StoredPhase.REFRESH_REQUIRED && + after.phase == StoredPhase.READY && + after.currentGeneration == before.currentGeneration + 1 + MatrixTransition.SERVER_WINS -> + before.phase == StoredPhase.REFRESH_REQUIRED && after.phase == StoredPhase.RETIRED + MatrixTransition.PARK -> after.phase == StoredPhase.PARKED + } +} + +private suspend fun assertJournalDeath( + cell: MatrixCell, + storage: FailPointJournalStorage, + block: suspend () -> Unit, +) { + val death = assertIs(captureMatrixFailure(block)) + assertEquals(cell.mode == MatrixKillMode.AFTER_COMMIT, death.committed) + assertTrue(!storage.hasArmedFailPoint) +} + +private suspend fun captureMatrixFailure(block: suspend () -> Unit): Throwable? = + try { + block() + null + } catch (failure: Throwable) { + failure + } + +private suspend fun assertRetired( + storage: MutationJournalStorage, + mutationId: String, + engine: MutationEngine, +): MatrixDurableState { + val state = storage.matrixState(mutationId) + assertEquals(StoredPhase.RETIRED, state.execution.phase) + assertTrue(engine.pendingWrites().none { it.mutationId == mutationId }) + assertTrue(engine.deadLetters().none { it.mutationId == mutationId }) + return state +} + +private suspend fun assertParked( + storage: MutationJournalStorage, + mutationId: String, + engine: MutationEngine, + failureKind: MutationFailureKind, +): MatrixDurableState { + val state = storage.matrixState(mutationId) + assertEquals(StoredPhase.PARKED, state.execution.phase) + assertEquals(failureKind, assertNotNull(state.activeFailure).kind) + assertTrue(engine.pendingWrites().none { it.mutationId == mutationId }) + assertEquals(mutationId, engine.deadLetters().single { it.mutationId == mutationId }.mutationId) + return state +} + +private fun assertEveryEffectivePushAppliedOnce(backend: FakeBackend) { + backend.effectivePushApplications.groupingBy { it }.eachCount().forEach { (_, count) -> + assertEquals(1, count) + } +} + +private fun assertPushHighWater( + backend: FakeBackend, + mutationId: String, + expectedReceipts: Int, + expectedDistinctKeys: Int, + expectedTotalReceipts: Int = expectedReceipts, +) { + assertEquals(expectedTotalReceipts, backend.receivedPushes.size) + assertEquals( + backend.receivedIdempotencyKeys, + backend.receivedPushes.map { request -> request.idempotencyKey }, + ) + val received = backend.receivedPushes.filter { request -> request.mutationId == mutationId } + assertEquals(expectedReceipts, received.size) + received.groupBy { request -> request.generation }.values.forEach { generationReceipts -> + assertEquals(1, generationReceipts.map { request -> request.idempotencyKey }.distinct().size) + } + assertEquals(expectedDistinctKeys, received.map { request -> request.generation }.distinct().size) + assertEquals(expectedDistinctKeys, received.map { request -> request.idempotencyKey }.distinct().size) +} + +private data class MatrixDurableState( + val execution: MutationExecutionRecord, + val attempts: List, + val activeFailure: MutationFailureRecord?, +) + +private suspend fun MutationJournalStorage.matrixState(mutationId: String): MatrixDurableState = + transaction { transaction -> + val intent = + transaction.intents(MATRIX_CLIENT_ID).single { intent -> intent.mutationId == mutationId } + val execution = + transaction.executions(MATRIX_CLIENT_ID).single { execution -> + execution.clientSequence == intent.clientSequence + } + MatrixDurableState( + execution = execution, + attempts = + transaction.attempts(MATRIX_CLIENT_ID).filter { attempt -> + attempt.clientSequence == intent.clientSequence + }, + activeFailure = + execution.activeFailureId?.let { activeFailureId -> + transaction.failures(MATRIX_CLIENT_ID).single { failure -> + failure.failureId == activeFailureId + } + }, + ) + } + +private suspend fun MutationJournalStorage.sequenceOf(mutationId: String): Long = + transaction { transaction -> + transaction.intents(MATRIX_CLIENT_ID).single { intent -> + intent.mutationId == mutationId + }.clientSequence + } + +private data class MatrixClientState( + val client: MutationClientRecord, + val intentSequences: List, + val executionSequences: List, +) + +private suspend fun MutationJournalStorage.matrixClientState(): MatrixClientState = + transaction { transaction -> + MatrixClientState( + client = checkNotNull(transaction.client(MATRIX_CLIENT_ID)), + intentSequences = transaction.intents(MATRIX_CLIENT_ID).map { it.clientSequence }, + executionSequences = transaction.executions(MATRIX_CLIENT_ID).map { it.clientSequence }, + ) + } + +private class MatrixMutations(effectful: Boolean) { + lateinit var set: MutatorRef + + private val staleTargets = + if (effectful) { + typedStales( + keys = setOf(MATRIX_EFFECT_KEY), + namespaces = setOf(MATRIX_EFFECT_NAMESPACE), + ) + } else { + noStales() + } + + val registry: MutatorRegistry = + mutatorRegistry { + set = + mutator( + id = "matrix-set", + version = 1, + codec = FixtureStringArgsCodec, + stales = staleTargets, + ) { _, value -> MutationPresence.Present(value) } + } +} + +private class MatrixState { + val values: MutableMap = mutableMapOf() + val staleKeys: MutableList = mutableListOf() + val staleNamespaces: MutableList = mutableListOf() +} + +private class MatrixHandle( + private val state: MatrixState, +) : StoreWriteHandle { + override suspend fun apply( + key: MutationsTestKey, + value: String, + ) { + state.values[key.identity()] = value + } + + override suspend fun markStale(key: MutationsTestKey) { + state.staleKeys += key.identity() + } + + override suspend fun confirmFresh( + key: MutationsTestKey, + etag: String?, + ) = Unit +} + +private data class MatrixConflictPolicy( + val precondition: ((MutationPreconditionCandidate) -> StoreMeta?)? = null, + val merge: + ((MutationPresence, MutationPresence, MutationPresence) -> + MutationConflictResolution)? = null, +) + +private fun openMatrixEngine( + storage: MutationJournalStorage, + registry: MutatorRegistry, + server: MutationServer, + state: MatrixState, + clock: TestWallClock, + resolver: MutationKeyResolver = MATRIX_KEY_RESOLVER, + conflicts: MatrixConflictPolicy? = null, +): MutationEngine { + val journal = + StorageBackedMutationJournal( + storage = storage, + registrations = registry.registrations, + clientId = MATRIX_CLIENT_ID, + hydrateOnFirstUse = true, + ) + return MutationEngine( + registry = registry, + server = server, + journal = journal, + keyResolver = resolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + conflicts = + conflicts?.let { policy -> + MutationConflictRegistration(policy.precondition, policy.merge) + }, + baseReader = { key -> state.values[key.identity()] ?: "base" }, + wallClock = clock, + backoffRandom = Random(MATRIX_MASTER_SEED), + clientId = MATRIX_CLIENT_ID, + namespaceInvalidation = { namespace -> state.staleNamespaces += namespace }, + ).also { engine -> engine.bind(MatrixHandle(state)) } +} + +private fun retainingMatrixBackend(): FakeBackend = + FakeBackend().apply { + dedupingPushBehavior = true + retireBehavior = { MutationRetirementAck(confirmedThroughSequence = 0L) } + } + +private class MatrixAckLossServer( + private val delegate: FakeBackend, +) : MutationServer { + private var loseNextAcknowledgement = true + + override suspend fun push( + request: MutationPush, + ): MutationAck { + val acknowledgement = delegate.push(request) + if (loseNextAcknowledgement) { + loseNextAcknowledgement = false + throw MatrixTransportAckLostException() + } + return acknowledgement + } + + override suspend fun retire(request: MutationRetirement): MutationRetirementAck = + delegate.retire(request) +} + +private class MatrixTransportAckLostException : + CancellationException("Matrix process death after server application and before ack delivery") + +private class MatrixMeta( + override val writtenAtEpochMillis: Long, + override val etag: String?, +) : StoreMeta + +private fun matrixConflict(meta: StoreMeta): StoreException = + StoreResults.exception( + StoreResults.conflict(meta, "matrix conflict"), + IllegalStateException("matrix backend conflict"), + ) + +private val MATRIX_KEY_RESOLVER = + MutationKeyResolver { identity -> + MutationsTestKey(identity.canonicalId, StoreNamespace(identity.namespace)) + } + +private val MATRIX_NAMESPACE = StoreNamespace("matrix") +private val MATRIX_EFFECT_NAMESPACE = StoreNamespace("matrix-effect-namespace") +private val MATRIX_EFFECT_KEY = MutationsTestKey("matrix-effect-key", MATRIX_NAMESPACE) +private const val MATRIX_CLIENT_ID: String = "matrix-client" +private const val MATRIX_MASTER_SEED: Int = 0x0237_0201 + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = 25.seconds, testBody = testBody) diff --git a/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationDrainSchedulingPropertyTest.kt b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationDrainSchedulingPropertyTest.kt new file mode 100644 index 000000000..243eef9b8 --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationDrainSchedulingPropertyTest.kt @@ -0,0 +1,443 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, +) + +package org.mobilenativefoundation.store6.mutations + +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.seam.StoreWriteHandle +import org.mobilenativefoundation.store6.mutations.storage.InMemoryMutationJournalStorage +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionPhase as StoredPhase +import org.mobilenativefoundation.store6.mutations.storage.MutationJournalStorage +import org.mobilenativefoundation.store6.testing.TestWallClock + +class MutationDrainSchedulingPropertyTest { + @Test + fun perKeyFifoWithParkedAndDeclinedHeads_neverBlocksOtherKeys_property() = runTest { + assertGeneratedPerKeySchedulingCases() + assertSameNamespaceNonownerStartsNoTransportWhileDistinctNamespaceOverlaps() + } + + @Test + fun noEntryDrainsAheadOfItsPolicyVerdict_property() = runTest { + assertGeneratedVerdictOrderingCases() + assertGlobalDrainSkipsBackoffIneligibleEntry() + assertGlobalDrainResumesPostAttemptOwnerBeforeEnumeratedNonowner() + } +} + +private suspend fun assertGeneratedPerKeySchedulingCases() { + val seeds = SchedulingRng(PER_KEY_MASTER_SEED) + repeat(GENERATED_SCHEDULING_CASES) { caseIndex -> + val caseSeed = seeds.nextLong() + val random = SchedulingRng(caseSeed) + val storage = InMemoryMutationJournalStorage() + val mutations = SchedulingMutations() + val backend = retainingSchedulingBackend() + val handle = SchedulingHandle() + val clock = TestWallClock(1_000L + caseIndex) + val engine = openSchedulingEngine(storage, mutations, backend, handle, clock) + val namespace = StoreNamespace("schedule-$caseIndex") + val keyCount = 3 + random.nextInt(3) + val keys = (0 until keyCount).map { keyIndex -> MutationsTestKey("key-$keyIndex", namespace) } + val expectedPushes = linkedMapOf>() + val parkedIds = mutableSetOf() + val declinedIds = mutableMapOf>() + var nextSequence = 0L + + keys.forEachIndexed { keyIndex, key -> + val verdict = + if (keyIndex == 0) SchedulingVerdict.EXECUTE else SchedulingVerdict.entries[random.nextInt(3)] + when (verdict) { + SchedulingVerdict.EXECUTE -> { + engine.mutate(key, mutations.append, "+$keyIndex-a") + val first = ++nextSequence + engine.mutate(key, mutations.append, "+$keyIndex-b") + val second = ++nextSequence + expectedPushes[key.identity()] = listOf(first, second) + } + + SchedulingVerdict.PARK -> { + val parked = engine.mutate(key, mutations.park, "park") + ++nextSequence + parkedIds += parked + engine.mutate(key, mutations.append, "+$keyIndex-after-park") + val suffix = ++nextSequence + expectedPushes[key.identity()] = listOf(suffix) + } + + SchedulingVerdict.DECLINE -> { + val head = engine.mutate(key, mutations.decline, "decline") + ++nextSequence + val suffix = engine.mutate(key, mutations.append, "+$keyIndex-blocked") + ++nextSequence + declinedIds[key.identity()] = listOf(head, suffix) + expectedPushes[key.identity()] = emptyList() + } + } + } + + engine.drain() + + val actualByKey = + backend.receivedPushes + .groupBy { request -> request.key.identity() } + .mapValues { (_, requests) -> requests.map { request -> request.clientSequence } } + val normalizedActual = keys.associate { key -> key.identity() to actualByKey[key.identity()].orEmpty() } + val context = "masterSeed=$PER_KEY_MASTER_SEED caseSeed=$caseSeed caseIndex=$caseIndex" + assertEquals(expectedPushes, normalizedActual, context) + assertEquals( + parkedIds, + engine.deadLetters().map { deadLetter -> deadLetter.mutationId }.toSet(), + context, + ) + declinedIds.forEach { (identity, mutationIds) -> + val key = keys.single { candidate -> candidate.identity() == identity } + assertEquals(mutationIds, engine.pending(key).map { pending -> pending.mutationId }, context) + } + expectedPushes.values.flatten().forEach { sequence -> + assertEquals(StoredPhase.RETIRED, storage.phaseForSequence(sequence), context) + } + } +} + +private suspend fun assertGeneratedVerdictOrderingCases() { + val seeds = SchedulingRng(VERDICT_MASTER_SEED) + repeat(GENERATED_SCHEDULING_CASES) { caseIndex -> + val caseSeed = seeds.nextLong() + val random = SchedulingRng(caseSeed) + val storage = InMemoryMutationJournalStorage() + val mutations = SchedulingMutations() + val backend = FakeBackend() + val handle = SchedulingHandle() + val engine = + openSchedulingEngine( + storage, + mutations, + backend, + handle, + TestWallClock(10_000L + caseIndex), + ) + val namespace = StoreNamespace("verdict-$caseIndex") + val keys = (0 until 4).map { keyIndex -> MutationsTestKey("key-$keyIndex", namespace) } + val expectedByKey = linkedMapOf>() + var nextSequence = 0L + + keys.forEachIndexed { keyIndex, key -> + when (SchedulingVerdict.entries[random.nextInt(3)]) { + SchedulingVerdict.EXECUTE -> { + engine.mutate(key, mutations.append, "+$keyIndex-first") + val first = ++nextSequence + engine.mutate(key, mutations.append, "+$keyIndex-second") + val second = ++nextSequence + expectedByKey[key.identity()] = listOf(first, second) + } + + SchedulingVerdict.PARK -> { + engine.mutate(key, mutations.park, "park") + ++nextSequence + engine.mutate(key, mutations.append, "+$keyIndex-after") + val suffix = ++nextSequence + expectedByKey[key.identity()] = listOf(suffix) + } + + SchedulingVerdict.DECLINE -> { + engine.mutate(key, mutations.decline, "decline") + ++nextSequence + engine.mutate(key, mutations.append, "+$keyIndex-blocked") + ++nextSequence + expectedByKey[key.identity()] = emptyList() + } + } + } + + engine.drain() + + val actualByKey = + backend.receivedPushes + .groupBy { request -> request.key.identity() } + .mapValues { (_, requests) -> requests.map { request -> request.clientSequence } } + val context = "masterSeed=$VERDICT_MASTER_SEED caseSeed=$caseSeed caseIndex=$caseIndex" + keys.forEach { key -> + val actual = actualByKey[key.identity()].orEmpty() + assertEquals(expectedByKey.getValue(key.identity()), actual, context) + assertEquals(actual.sorted(), actual, context) + } + } +} + +private suspend fun TestScope.assertSameNamespaceNonownerStartsNoTransportWhileDistinctNamespaceOverlaps() { + val storage = InMemoryMutationJournalStorage() + val mutations = SchedulingMutations() + val backend = retainingSchedulingBackend() + val handle = SchedulingHandle() + val clock = TestWallClock(20_000L) + val namespace = StoreNamespace("authority") + val owner = MutationsTestKey("owner", namespace) + val nonowner = MutationsTestKey("nonowner", namespace) + val other = MutationsTestKey("other", StoreNamespace("authority-other")) + val ownerEntered = CompletableDeferred() + val releaseOwner = CompletableDeferred() + backend.pushBehavior = { key, value -> + if (key.identity() == owner.identity()) { + ownerEntered.complete(Unit) + releaseOwner.await() + } + MutationPresentAck(value, "authority-${backend.receivedPushes.size}", null) + } + val engine = openSchedulingEngine(storage, mutations, backend, handle, clock) + val ownerId = engine.mutate(owner, mutations.append, "+owner") + val nonownerId = engine.mutate(nonowner, mutations.append, "+nonowner") + val otherId = engine.mutate(other, mutations.append, "+other") + val ownerDrain = backgroundScope.async { engine.drain(owner) } + ownerEntered.await() + + try { + engine.drain(nonowner) + val otherDrain = backgroundScope.async { engine.drain(other) } + otherDrain.await() + + assertEquals( + listOf(owner.identity(), other.identity()), + backend.receivedPushes.map { request -> request.key.identity() }, + ) + assertEquals(StoredPhase.INFLIGHT, storage.phaseForMutation(ownerId)) + assertEquals(StoredPhase.UNPREPARED, storage.phaseForMutation(nonownerId)) + assertEquals(StoredPhase.RETIRED, storage.phaseForMutation(otherId)) + } finally { + releaseOwner.complete(Unit) + ownerDrain.await() + } + + engine.drain(nonowner) + assertEquals(StoredPhase.RETIRED, storage.phaseForMutation(nonownerId)) +} + +private suspend fun assertGlobalDrainSkipsBackoffIneligibleEntry() { + val storage = InMemoryMutationJournalStorage() + val mutations = SchedulingMutations() + val backend = retainingSchedulingBackend() + val handle = SchedulingHandle() + val clock = TestWallClock(25_000L) + val delayed = MutationsTestKey("backoff-delayed", StoreNamespace("backoff-delayed")) + val other = MutationsTestKey("backoff-other", StoreNamespace("backoff-other")) + var delayedAttempts = 0 + backend.pushBehavior = { key, value -> + if (key.identity() == delayed.identity() && delayedAttempts++ == 0) { + throw IllegalStateException("enter deterministic backoff") + } + MutationPresentAck(value, "backoff-${backend.receivedPushes.size}", null) + } + val engine = openSchedulingEngine(storage, mutations, backend, handle, clock) + val delayedId = engine.mutate(delayed, mutations.append, "+delayed") + val otherId = engine.mutate(other, mutations.append, "+other") + + engine.drain(delayed) + assertEquals(StoredPhase.READY, storage.phaseForMutation(delayedId)) + engine.drain() + + assertEquals( + listOf(delayed.identity(), other.identity()), + backend.receivedPushes.map { request -> request.key.identity() }, + ) + assertEquals(StoredPhase.READY, storage.phaseForMutation(delayedId)) + assertEquals(StoredPhase.RETIRED, storage.phaseForMutation(otherId)) + + clock.setEpochMillis(35_000L) + engine.drain() + assertEquals(StoredPhase.RETIRED, storage.phaseForMutation(delayedId)) +} + +private suspend fun assertGlobalDrainResumesPostAttemptOwnerBeforeEnumeratedNonowner() { + val storage = InMemoryMutationJournalStorage() + val mutations = SchedulingMutations() + val firstBackend = FakeBackend() + val handle = SchedulingHandle() + val clock = TestWallClock(30_000L) + val namespace = StoreNamespace("global-authority") + val nonowner = MutationsTestKey("enumerated-first", namespace) + val owner = MutationsTestKey("post-attempt-owner", namespace) + val other = MutationsTestKey("eligible-other", StoreNamespace("global-authority-other")) + firstBackend.pushBehavior = { key, value -> + if (key.identity() == owner.identity()) { + throw IllegalStateException("retain post-attempt authority") + } + MutationPresentAck(value, "seed", null) + } + val first = openSchedulingEngine(storage, mutations, firstBackend, handle, clock) + val nonownerId = first.mutate(nonowner, mutations.append, "+nonowner") + val ownerId = first.mutate(owner, mutations.append, "+owner") + val otherId = first.mutate(other, mutations.append, "+other") + first.drain(owner) + assertEquals(StoredPhase.READY, storage.phaseForMutation(ownerId)) + + clock.setEpochMillis(40_000L) + val replayBackend = retainingSchedulingBackend() + val reopened = openSchedulingEngine(storage, mutations, replayBackend, handle, clock) + reopened.drain() + + assertEquals( + listOf(owner.identity(), other.identity()), + replayBackend.receivedPushes.map { request -> request.key.identity() }, + ) + assertEquals(StoredPhase.UNPREPARED, storage.phaseForMutation(nonownerId)) + assertEquals(StoredPhase.RETIRED, storage.phaseForMutation(ownerId)) + assertEquals(StoredPhase.RETIRED, storage.phaseForMutation(otherId)) + + reopened.drain() + assertEquals(StoredPhase.RETIRED, storage.phaseForMutation(nonownerId)) +} + +private class SchedulingMutations { + lateinit var append: MutatorRef + lateinit var park: MutatorRef + lateinit var decline: MutatorRef + + val registry: MutatorRegistry = + mutatorRegistry { + append = + mutator( + id = "scheduling-append", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { base, suffix -> + MutationPresence.Present( + (base as? MutationPresence.Present)?.value.orEmpty() + suffix, + ) + } + park = + mutator( + id = "scheduling-park", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { _, _ -> + throw IllegalStateException("scripted property park") + } + decline = + mutator( + id = "scheduling-decline", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { _, _ -> null } + } +} + +private class SchedulingHandle : StoreWriteHandle { + private val values = mutableMapOf() + + fun read(key: MutationsTestKey): String = values[key.identity()] ?: "base" + + override suspend fun apply( + key: MutationsTestKey, + value: String, + ) { + values[key.identity()] = value + } + + override suspend fun markStale(key: MutationsTestKey) = Unit + + override suspend fun confirmFresh( + key: MutationsTestKey, + etag: String?, + ) = Unit +} + +private fun retainingSchedulingBackend(): FakeBackend = + FakeBackend().apply { + retireBehavior = { + MutationRetirementAck(confirmedThroughSequence = 0L) + } + } + +private fun openSchedulingEngine( + storage: MutationJournalStorage, + mutations: SchedulingMutations, + backend: FakeBackend, + handle: SchedulingHandle, + clock: TestWallClock, +): MutationEngine { + val journal = + StorageBackedMutationJournal( + storage = storage, + registrations = mutations.registry.registrations, + clientId = SCHEDULING_CLIENT_ID, + hydrateOnFirstUse = true, + ) + return MutationEngine( + registry = mutations.registry, + server = backend, + journal = journal, + keyResolver = SCHEDULING_KEY_RESOLVER, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + baseReader = { key -> handle.read(key) }, + wallClock = clock, + backoffRandom = Random(0), + clientId = SCHEDULING_CLIENT_ID, + ).also { engine -> engine.bind(handle) } +} + +private suspend fun MutationJournalStorage.phaseForMutation(mutationId: String): StoredPhase = + transaction { transaction -> + val sequence = + transaction.intents(SCHEDULING_CLIENT_ID).single { intent -> + intent.mutationId == mutationId + }.clientSequence + transaction.executions(SCHEDULING_CLIENT_ID).single { execution -> + execution.clientSequence == sequence + }.phase + } + +private suspend fun MutationJournalStorage.phaseForSequence(sequence: Long): StoredPhase = + transaction { transaction -> + transaction.executions(SCHEDULING_CLIENT_ID).single { execution -> + execution.clientSequence == sequence + }.phase + } + +private enum class SchedulingVerdict { + EXECUTE, + PARK, + DECLINE, +} + +private class SchedulingRng(seed: Long) { + private var state = seed + + fun nextInt(bound: Int): Int { + require(bound > 0) + state = state * 6364136223846793005L + 1442695040888963407L + return ((state ushr 1) % bound.toLong()).toInt() + } + + fun nextLong(): Long { + state = state * 6364136223846793005L + 1442695040888963407L + return state + } +} + +private val SCHEDULING_KEY_RESOLVER = + MutationKeyResolver { identity -> + MutationsTestKey(identity.canonicalId, StoreNamespace(identity.namespace)) + } + +private const val SCHEDULING_CLIENT_ID: String = "scheduling-client" +private const val GENERATED_SCHEDULING_CASES: Int = 64 +private const val PER_KEY_MASTER_SEED: Long = 0x0237_0301_2026L +private const val VERDICT_MASTER_SEED: Long = 0x0237_0302_2026L + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = 25.seconds, testBody = testBody) diff --git a/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationDrainTest.kt b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationDrainTest.kt new file mode 100644 index 000000000..d2505fd1d --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationDrainTest.kt @@ -0,0 +1,659 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, +) + +package org.mobilenativefoundation.store6.mutations + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.seam.StoreWriteHandle +import org.mobilenativefoundation.store6.mutations.storage.InMemoryMutationJournalStorage +import org.mobilenativefoundation.store6.mutations.storage.MutationEffectDisposition +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionPhase as StoredPhase +import org.mobilenativefoundation.store6.mutations.storage.MutationJournalStorage +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +/** + * `drain(key)` and `drain()` are idempotent, scheduler-agnostic foreground passes, and the + * resolver — not any live key map — is global drain's correctness path. Restart enumeration is + * covered by `MutationJournalContractTest`; parked-identity continuation, retryable post-ack + * continuation, and the one-attempt-per-phase rule by `MutationDrainParkingTest` and + * `MutationDrainResumabilityMatrixTest`. + */ +class MutationDrainTest { + @Test + fun globalDrain_reconstructsKeyAfterLiveKeyCacheIsCleared() = runTest { + val mutation = DrainRenameMutation() + val backend = FakeBackend() + val resolverCalls = mutableListOf>() + val engine = + MutationEngine( + registry = mutation.registry, + server = backend, + keyResolver = + MutationKeyResolver { identity -> + resolverCalls += identity.namespace to identity.canonicalId + MutationsTestKeyResolver.resolve(identity) + }, + baseReader = { "base" }, + ) + engine.bind(DrainNoopHandle) + val key = MutationsTestKey("reconstructed") + engine.mutate(key, mutation.ref, "recovered-value") + + // The live key map is cache only: with every cached resolution dropped, the durable + // journal identity plus the resolver must reconstruct K or the drain cannot proceed. + engine.clearLiveKeyCache() + engine.drain() + + assertEquals(listOf("mutations" to "reconstructed"), resolverCalls) + assertEquals(listOf("recovered-value"), backend.pushedValues) + assertEquals("reconstructed", backend.receivedPushes.single().identity.canonicalId) + assertEquals(emptyList(), engine.pending(key)) + assertEquals(emptyList(), engine.drainFailuresForInspection()) + } + + @Test + fun keyedDrain_isIdempotentForegroundPass() = runTest { + val mutation = DrainRenameMutation() + val backend = FakeBackend() + val key = MutationsTestKey("keyed-idempotent") + val untouched = MutationsTestKey("keyed-untouched") + val users = + mutationStore( + registry = mutation.registry, + server = backend, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + ) { + fetcher { backend.load(it) } + } + + try { + assertEquals("base", users.get(key)) + val fetchesBeforeDrain = backend.fetchCount + users.mutate(key, mutation.ref, "pending") + + users.drain(key) + + assertEquals(listOf("pending"), backend.pushedValues) + assertEquals(emptyList(), users.pending(key)) + + // A second keyed pass over retired work and a pass over a key that never had work + // are both no-ops: no push, no fetch, no invented failure (D12/D14). + users.drain(key) + users.drain(untouched) + + assertEquals(listOf("pending"), backend.pushedValues) + assertEquals(fetchesBeforeDrain, backend.fetchCount) + assertEquals(emptyList(), users.pending(key)) + } finally { + users.close() + } + } + + @Test + fun globalDrain_processesMultipleIdentitiesDeterministically() = runTest { + val mutation = DrainAppendMutation() + val backend = FakeBackend() + val first = MutationsTestKey("multi-a") + val second = MutationsTestKey("multi-b") + val third = MutationsTestKey("multi-c") + val users = + mutationStore( + registry = mutation.registry, + server = backend, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + ) { + fetcher { backend.load(it) } + } + + try { + users.mutate(first, mutation.ref, "+a1") + users.mutate(first, mutation.ref, "+a2") + users.mutate(second, mutation.ref, "+b") + users.mutate(third, mutation.ref, "+c") + + users.drain() + + // Identities are processed in first-enqueue order and each identity's intents flush + // by durable client sequence; the second first-key push rides the first's echo. + assertEquals( + listOf("multi-a", "multi-a", "multi-b", "multi-c"), + backend.receivedPushes.map { push -> push.identity.canonicalId }, + ) + assertEquals(listOf("+a1", "+a1+a2", "+b", "+c"), backend.pushedValues) + val firstKeySequences = + backend.receivedPushes + .filter { push -> push.identity.canonicalId == "multi-a" } + .map(MutationPush::clientSequence) + assertEquals(firstKeySequences, firstKeySequences.sorted()) + assertEquals(emptyList(), users.pendingWrites()) + + // Re-running over fully retired work changes nothing. + users.drain() + assertEquals(4, backend.receivedPushes.size) + } finally { + users.close() + } + } + + @Test + fun emptyGlobalDrain_isIdempotentNoOp() = runTest { + val mutation = DrainRenameMutation() + val backend = FakeBackend() + val users = + mutationStore( + registry = mutation.registry, + server = backend, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + ) { + fetcher { backend.load(it) } + } + + try { + users.drain() + users.drain() + + assertEquals(emptyList(), backend.receivedPushes) + assertEquals(0, backend.fetchCount) + assertEquals(emptyList(), users.pendingWrites()) + assertEquals(emptyList(), users.deadLetters()) + } finally { + users.close() + } + } + + @Test + fun oneResolverFailure_doesNotBlockAnotherIdentity() = runTest { + val mutation = DrainRenameMutation() + val backend = FakeBackend() + val engine = + MutationEngine( + registry = mutation.registry, + server = backend, + keyResolver = + MutationKeyResolver { identity -> + if (identity.canonicalId == "poisoned-identity") { + throw IllegalStateException("cold lookup failed") + } + MutationsTestKeyResolver.resolve(identity) + }, + baseReader = { "base" }, + ) + engine.bind(DrainNoopHandle) + val poisoned = MutationsTestKey("poisoned-identity") + val healthy = MutationsTestKey("healthy-identity") + // The failing identity enqueues first so continuation past it is what the test proves. + val poisonedId = engine.mutate(poisoned, mutation.ref, "never-pushed") + engine.mutate(healthy, mutation.ref, "healthy-value") + engine.clearLiveKeyCache() + + engine.drain() + + assertEquals(listOf("healthy-value"), backend.pushedValues) + assertEquals(emptyList(), engine.pending(healthy)) + assertEquals( + listOf(poisonedId), + engine.pending(poisoned).map(PendingIntent::mutationId), + ) + val failure = engine.drainFailuresForInspection().single() + assertEquals(MutationFailureKind.IDENTITY, failure.kind) + assertEquals(DRAIN_FAILURE_DETAIL_RESOLVER_THROW, failure.detail) + assertEquals("cold lookup failed", failure.message) + } + + @Test + fun declinedHead_leavesSuffixPendingAndGlobalDrainProcessesOtherIdentities() = runTest { + lateinit var strictUpdate: MutatorRef + lateinit var rename: MutatorRef + val registry = + mutatorRegistry { + strictUpdate = + update( + id = "strict-update", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { _, value -> value } + rename = + mutator( + id = "rename", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { _, value -> MutationPresence.Present(value) } + } + val backend = FakeBackend() + val engine = + MutationEngine( + registry = registry, + server = backend, + keyResolver = MutationsTestKeyResolver, + baseReader = { key -> if (key.canonicalId() == "declined-key") null else "base" }, + ) + engine.bind(DrainNoopHandle) + val declined = MutationsTestKey("declined-key") + val healthy = MutationsTestKey("healthy-key") + // `update` over a stably absent base declines: the head stays PENDING and blocks + // only its own same-effective-key suffix. + val declinedHead = engine.mutate(declined, strictUpdate, "never-applies") + val blockedSuffix = engine.mutate(declined, rename, "blocked-behind-decline") + engine.mutate(healthy, rename, "healthy-value") + + engine.drain() + + assertEquals(listOf("healthy-value"), backend.pushedValues) + assertEquals( + listOf(declinedHead, blockedSuffix), + engine.pending(declined).map(PendingIntent::mutationId), + ) + assertEquals(emptyList(), engine.pending(healthy)) + // A deliberate decline is not a failure: no normalized carrier and no poison. + assertEquals(emptyList(), engine.drainFailuresForInspection()) + assertTrue(engine.poisoned.replayCache.isEmpty()) + } + + @Test + fun foregroundPassAttemptsEachFailedLocalPhaseOnlyOnce() = runTest { + val backing = InMemoryMutationJournalStorage() + val storage = FailPointJournalStorage(backing) + val mutations = DrainLocalPhaseMutations() + val backend = FakeBackend() + val handle = ScriptedDrainPhaseHandle() + val engine = openDrainPhaseEngine(storage, mutations, backend, handle) + val adoption = drainPhaseKey("adoption") + val effect = drainPhaseKey("effect") + val finalization = drainPhaseKey("finalization") + + suspend fun stage( + key: MutationsTestKey, + ref: MutatorRef, + boundary: JournalFailPointBoundary, + ): String { + val mutationId = engine.mutate(key, ref, "+${key.canonicalId()}") + storage.armFailTransaction(boundary) + assertIs(captureDrainPhaseFailure { engine.drain(key) }) + return mutationId + } + + val adoptionId = stage(adoption, mutations.plain, JournalFailPointBoundary.ADOPTION_ADVANCE) + // Keep effect 2 PENDING after effect 1's one-shot marker failure during this setup pass. + handle.remainingMarkStaleFailures[mutations.effectTargets(effect).last().identity()] = 1 + val effectId = stage(effect, mutations.effectful, JournalFailPointBoundary.EFFECT_MARKER) + val finalizationId = + stage(finalization, mutations.plain, JournalFailPointBoundary.FINALIZATION) + + handle.applyAttempts.clear() + handle.markStaleAttempts.clear() + handle.remainingApplyFailures[adoption.identity()] = 1 + mutations.effectTargets(effect).forEach { target -> + handle.remainingMarkStaleFailures[target.identity()] = 1 + } + storage.triggeredBoundaries.clear() + storage.armFailTransaction(JournalFailPointBoundary.FINALIZATION) + val pushCount = backend.receivedPushes.size + + val exposed = captureDrainPhaseFailure { engine.drain() } + val (phases, effectDispositions) = + backing.transaction { transaction -> + val intents = transaction.intents(DRAIN_PHASE_CLIENT_ID).associateBy { it.mutationId } + val phases = + transaction.executions(DRAIN_PHASE_CLIENT_ID).associate { execution -> + val mutationId = + intents.values.single { intent -> + intent.clientSequence == execution.clientSequence + }.mutationId + mutationId to execution.phase + } + val effectSequence = intents.getValue(effectId).clientSequence + val effectDispositions = + transaction + .effects(DRAIN_PHASE_CLIENT_ID) + .filter { effect -> effect.clientSequence == effectSequence } + .sortedBy { effect -> effect.effectIndex } + .map { effect -> effect.disposition } + phases to effectDispositions + } + val deadLetters = engine.deadLetters() + val errors = mutableListOf() + expectDrainPhase(errors, "global foreground pass returns normally") { assertNull(exposed) } + expectDrainPhase(errors, "adoption is attempted once") { + assertEquals(listOf(adoption.identity()), handle.applyAttempts) + assertEquals(StoredPhase.ACKED, phases.getValue(adoptionId)) + } + expectDrainPhase(errors, "each runnable effect is attempted once") { + assertEquals( + mutations.effectTargets(effect).map { target -> target.identity() }, + handle.markStaleAttempts, + ) + assertEquals( + listOf( + MutationEffectDisposition.PENDING, + MutationEffectDisposition.PENDING, + ), + effectDispositions, + ) + assertEquals(StoredPhase.EFFECTS_PENDING, phases.getValue(effectId)) + } + expectDrainPhase(errors, "finalization is attempted once") { + assertEquals(listOf(JournalFailPointBoundary.FINALIZATION), storage.triggeredBoundaries) + assertEquals(StoredPhase.EFFECTS_PENDING, phases.getValue(finalizationId)) + } + expectDrainPhase(errors, "no failed local phase repushes or parks") { + assertEquals(pushCount, backend.receivedPushes.size) + assertTrue(deadLetters.isEmpty()) + } + assertTrue(errors.isEmpty(), errors.joinToString(separator = "\n")) + } + + @Test + fun pushCancellation_rethrowsAndReplayUsesExactInflightGeneration() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutation = DrainAppendMutation() + val backend = + FakeBackend().apply { + retireBehavior = { + MutationRetirementAck(confirmedThroughSequence = 0L) + } + } + val pushEntered = CompletableDeferred() + val releasePush = CompletableDeferred() + backend.pushBehavior = { _, value -> + pushEntered.complete(Unit) + releasePush.await() + MutationPresentAck(value, "etag-replayed", null) + } + val key = MutationsTestKey("cancelled-inflight-replay") + val first = + mutationStore( + registry = mutation.registry, + server = backend, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + ) { + fetcher { backend.load(it) } + journalStorage(storage) + } + + val firstPush: MutationPush + try { + assertEquals("base", first.get(key)) + first.mutate(key, mutation.ref, "+pending") + val draining = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + first.drain(key) + } + pushEntered.await() + assertFalse(draining.isCompleted) + firstPush = backend.receivedPushes.single() + + val inflight = storage.transaction { it.executions("client-0").single() } + assertEquals(StoredPhase.INFLIGHT, inflight.phase) + assertEquals(0, inflight.attempt) + assertNull(inflight.lastAttemptAt) + assertNull(inflight.activeFailureId) + assertEquals(emptyList(), storage.transaction { it.failures("client-0") }) + + draining.cancel(CancellationException("host cancelled the in-flight push")) + assertFailsWith { draining.await() } + + val afterCancellation = storage.transaction { it.executions("client-0").single() } + assertEquals(StoredPhase.INFLIGHT, afterCancellation.phase) + assertEquals(0, afterCancellation.attempt) + assertNull(afterCancellation.lastAttemptAt) + assertNull(afterCancellation.activeFailureId) + assertEquals(emptyList(), storage.transaction { it.failures("client-0") }) + } finally { + releasePush.complete(Unit) + first.close() + } + + backend.pushBehavior = { _, value -> MutationPresentAck(value, "etag-replayed", null) } + val reopened = + mutationStore( + registry = mutation.registry, + server = backend, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + ) { + fetcher { backend.load(it) } + journalStorage(storage) + } + try { + reopened.drain(key) + val replay = backend.receivedPushes.last() + + assertEquals(2, backend.receivedPushes.size) + assertEquals(firstPush.clientId, replay.clientId) + assertEquals(firstPush.clientSequence, replay.clientSequence) + assertEquals(firstPush.mutationId, replay.mutationId) + assertEquals(firstPush.generation, replay.generation) + assertEquals(firstPush.identity.namespace, replay.identity.namespace) + assertEquals(firstPush.identity.canonicalId, replay.identity.canonicalId) + assertEquals(firstPush.retiredThroughSequence, replay.retiredThroughSequence) + assertEquals(firstPush.idempotencyKey, replay.idempotencyKey) + assertEquals(firstPush.valueCodecVersion, replay.valueCodecVersion) + assertEquals( + assertIs>(firstPush.base).value, + assertIs>(replay.base).value, + ) + assertEquals( + assertIs>(firstPush.mine).value, + assertIs>(replay.mine).value, + ) + assertEquals(firstPush.baseMeta?.writtenAtEpochMillis, replay.baseMeta?.writtenAtEpochMillis) + assertEquals(firstPush.baseMeta?.etag, replay.baseMeta?.etag) + assertEquals( + StoredPhase.RETIRED, + storage.transaction { it.executions("client-0").single().phase }, + ) + assertEquals(emptyList(), storage.transaction { it.failures("client-0") }) + } finally { + reopened.close() + } + } +} + +private class DrainRenameMutation { + lateinit var ref: MutatorRef + val registry: MutatorRegistry = + mutatorRegistry { + ref = + mutator( + id = "rename", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { _, value -> MutationPresence.Present(value) } + } +} + +private class DrainAppendMutation { + lateinit var ref: MutatorRef + val registry: MutatorRegistry = + mutatorRegistry { + ref = + mutator( + id = "append", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { base, suffix -> + MutationPresence.Present( + ((base as? MutationPresence.Present)?.value).orEmpty() + suffix, + ) + } + } +} + +private class DrainLocalPhaseMutations { + lateinit var plain: MutatorRef + lateinit var effectful: MutatorRef + + val registry: MutatorRegistry = + mutatorRegistry { + plain = + upsert( + id = "drain-phase-plain", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { base, suffix -> + MutationPresence.Present( + (base as? MutationPresence.Present)?.value.orEmpty() + suffix, + ) + } + effectful = + upsert( + id = "drain-phase-effectful", + version = 1, + codec = FixtureStringArgsCodec, + stales = { key, _ -> + StaleSet( + keys = effectTargets(key).toSet(), + namespaces = emptySet(), + ) + }, + ) { base, suffix -> + MutationPresence.Present( + (base as? MutationPresence.Present)?.value.orEmpty() + suffix, + ) + } + } + + fun effectTargets(key: MutationsTestKey): List = + listOf( + MutationsTestKey("${key.canonicalId()}-target-1", key.namespace), + MutationsTestKey("${key.canonicalId()}-target-2", key.namespace), + ) +} + +private class ScriptedDrainPhaseHandle : StoreWriteHandle { + val remainingApplyFailures = mutableMapOf() + val remainingMarkStaleFailures = mutableMapOf() + val applyAttempts = mutableListOf() + val markStaleAttempts = mutableListOf() + + override suspend fun apply( + key: MutationsTestKey, + value: String, + ) { + val identity = key.identity() + applyAttempts += identity + val remaining = remainingApplyFailures[identity] ?: 0 + if (remaining > 0) { + remainingApplyFailures[identity] = remaining - 1 + throw IllegalStateException("scripted adoption failure") + } + } + + override suspend fun markStale(key: MutationsTestKey) { + val identity = key.identity() + markStaleAttempts += identity + val remaining = remainingMarkStaleFailures[identity] ?: 0 + if (remaining > 0) { + remainingMarkStaleFailures[identity] = remaining - 1 + throw IllegalStateException("scripted effect failure") + } + } + + override suspend fun confirmFresh( + key: MutationsTestKey, + etag: String?, + ) = Unit +} + +private fun openDrainPhaseEngine( + storage: MutationJournalStorage, + mutations: DrainLocalPhaseMutations, + backend: FakeBackend, + handle: ScriptedDrainPhaseHandle, +): MutationEngine { + val journal = + StorageBackedMutationJournal( + storage = storage, + registrations = mutations.registry.registrations, + clientId = DRAIN_PHASE_CLIENT_ID, + hydrateOnFirstUse = true, + ) + val resolver = + MutationKeyResolver { identity -> + MutationsTestKey(identity.canonicalId, StoreNamespace(identity.namespace)) + } + return MutationEngine( + registry = mutations.registry, + server = backend, + journal = journal, + keyResolver = resolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + baseReader = { "base" }, + clientId = DRAIN_PHASE_CLIENT_ID, + ).also { engine -> engine.bind(handle) } +} + +private fun drainPhaseKey(label: String): MutationsTestKey = + MutationsTestKey(label, StoreNamespace("drain-phase-$label")) + +private suspend fun captureDrainPhaseFailure(block: suspend () -> Unit): Throwable? = + try { + block() + null + } catch (failure: Throwable) { + if (failure is CancellationException) throw failure + failure + } + +private fun expectDrainPhase( + errors: MutableList, + label: String, + assertion: () -> Unit, +) { + try { + assertion() + } catch (failure: AssertionError) { + errors += "$label: ${failure.message}" + } +} + +private object DrainNoopHandle : StoreWriteHandle { + override suspend fun apply( + key: MutationsTestKey, + value: String, + ) = Unit + + override suspend fun markStale(key: MutationsTestKey) = Unit + + override suspend fun confirmFresh( + key: MutationsTestKey, + etag: String?, + ) = Unit +} + +private const val DRAIN_PHASE_CLIENT_ID: String = "client-0" + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = 25.seconds, testBody = testBody) diff --git a/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationDrainTriggerTest.kt b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationDrainTriggerTest.kt new file mode 100644 index 000000000..e7779a538 --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationDrainTriggerTest.kt @@ -0,0 +1,200 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, + kotlinx.coroutines.ExperimentalCoroutinesApi::class, +) + +package org.mobilenativefoundation.store6.mutations + +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.seam.WallClock +import org.mobilenativefoundation.store6.mutations.storage.InMemoryMutationJournalStorage +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionPhase as StoredPhase +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.seconds + +class MutationDrainTriggerTest { + @Test + fun launchTrigger_hostDrainAfterConstructionResumesPendingWork() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = TriggerMutations() + val backend = retainingTriggerBackend() + val clock = SchedulerTriggerClock { testScheduler.currentTime } + val key = MutationsTestKey("launch-trigger") + + openTriggerStore(storage, mutations, backend, clock).use { first -> + first.mutate(key, mutations.append, "+pending") + } + assertEquals(emptyList(), backend.receivedPushes) + + openTriggerStore(storage, mutations, backend, clock).use { launched -> + testScheduler.runCurrent() + assertEquals(emptyList(), backend.receivedPushes) + assertEquals(StoredPhase.UNPREPARED, triggerExecutionPhase(storage)) + + launched.drain() + assertEquals(emptyList(), launched.pending(key)) + } + + assertEquals(listOf("launch-trigger"), backend.receivedPushes.map { it.key.canonicalId() }) + assertEquals(StoredPhase.RETIRED, triggerExecutionPhase(storage)) + } + + @Test + fun connectivityRegainTrigger_hostDrainFlushesOfflineBacklog() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = TriggerMutations() + val backend = retainingTriggerBackend().apply { offline = true } + val clock = SchedulerTriggerClock { testScheduler.currentTime } + val key = MutationsTestKey("connectivity-trigger") + + openTriggerStore(storage, mutations, backend, clock).use { users -> + users.mutate(key, mutations.append, "+offline") + users.drain() + assertEquals(StoredPhase.READY, triggerExecutionPhase(storage)) + assertEquals(1, triggerAttempt(storage)) + assertEquals(listOf(key.canonicalId()), users.pendingWrites().map { it.canonicalId }) + + backend.offline = false + testScheduler.advanceTimeBy(1_001L) + users.drain() + + assertEquals(emptyList(), users.pendingWrites()) + assertEquals(StoredPhase.RETIRED, triggerExecutionPhase(storage)) + } + assertEquals(listOf("+offline"), backend.pushedValues) + } + + @Test + fun postMutationTrigger_keyedDrainPushesWithoutAutoScheduler() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = TriggerMutations() + val backend = FakeBackend() + val clock = SchedulerTriggerClock { testScheduler.currentTime } + val key = MutationsTestKey("post-mutation-trigger") + + openTriggerStore(storage, mutations, backend, clock).use { users -> + users.mutate(key, mutations.append, "+pending") + assertEquals(emptyList(), backend.receivedPushes) + + users.drain(key) + + assertEquals(1, backend.receivedPushes.size) + assertEquals(emptyList(), users.pending(key)) + } + } + + @Test + fun mutateNeverStartsATransportPush() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = TriggerMutations() + val backend = FakeBackend() + val clock = SchedulerTriggerClock { testScheduler.currentTime } + val key = MutationsTestKey("mutate-no-push") + + openTriggerStore(storage, mutations, backend, clock).use { users -> + val mutationId = users.mutate(key, mutations.append, "+pending") + testScheduler.runCurrent() + + assertEquals(emptyList(), backend.receivedPushes) + assertEquals(listOf(mutationId), users.pending(key).map(PendingIntent::mutationId)) + assertEquals(StoredPhase.UNPREPARED, triggerExecutionPhase(storage)) + } + } + + @Test + fun hydrationNeverStartsADrainPass() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = TriggerMutations() + val backend = FakeBackend() + val clock = SchedulerTriggerClock { testScheduler.currentTime } + val key = MutationsTestKey("hydration-no-drain") + + openTriggerStore(storage, mutations, backend, clock).use { first -> + first.mutate(key, mutations.append, "+pending") + } + assertEquals(StoredPhase.UNPREPARED, triggerExecutionPhase(storage)) + + openTriggerStore(storage, mutations, backend, clock).use { reopened -> + assertEquals("base", reopened.get(key)) + assertEquals(1, reopened.pending(key).size) + testScheduler.runCurrent() + + assertEquals(emptyList(), backend.receivedPushes) + assertEquals(StoredPhase.UNPREPARED, triggerExecutionPhase(storage)) + assertEquals(0, triggerAttempt(storage)) + } + } +} + +private const val TRIGGER_CLIENT_ID: String = "client-0" + +private class TriggerMutations { + lateinit var append: MutatorRef + + val registry: MutatorRegistry = + mutatorRegistry { + append = + mutator( + id = "trigger-append", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { base, suffix -> + MutationPresence.Present( + ((base as? MutationPresence.Present)?.value).orEmpty() + suffix, + ) + } + } +} + +private class SchedulerTriggerClock( + private val now: () -> Long, +) : WallClock { + override fun nowEpochMillis(): Long = now() +} + +private fun retainingTriggerBackend(): FakeBackend = + FakeBackend().apply { + retireBehavior = { + MutationRetirementAck(confirmedThroughSequence = 0L) + } + } + +private fun openTriggerStore( + storage: InMemoryMutationJournalStorage, + mutations: TriggerMutations, + backend: FakeBackend, + wallClock: WallClock, +): MutationStore = + mutationStore( + registry = mutations.registry, + server = backend, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + ) { + fetcher { backend.load(it) } + journalStorage(storage) + wallClock(wallClock) + } + +private inline fun + MutationStore.use(block: (MutationStore) -> R): R = + try { + block(this) + } finally { + close() + } + +private suspend fun triggerExecutionPhase(storage: InMemoryMutationJournalStorage): StoredPhase = + storage.transaction { transaction -> transaction.executions(TRIGGER_CLIENT_ID).single().phase } + +private suspend fun triggerAttempt(storage: InMemoryMutationJournalStorage): Int = + storage.transaction { transaction -> transaction.executions(TRIGGER_CLIENT_ID).single().attempt } + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = 25.seconds, testBody = testBody) diff --git a/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationEffectsTest.kt b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationEffectsTest.kt new file mode 100644 index 000000000..bc16ef059 --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationEffectsTest.kt @@ -0,0 +1,179 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, +) + +package org.mobilenativefoundation.store6.mutations + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.StoreNamespace +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.time.Duration.Companion.seconds + +class MutationEffectsTest { + @Test + fun staleSet_isCopiedNormalizedDeduplicatedAndSorted() = runTest { + val duplicateA = MutationsTestKey("alpha") + val duplicateB = MutationsTestKey("alpha") + val zulu = MutationsTestKey("zulu") + val mutableKeys = mutableSetOf(zulu, duplicateA, duplicateB) + val mutableNamespaces = + mutableSetOf(StoreNamespace("zzz"), StoreNamespace("aaa"), StoreNamespace("aaa")) + val staleSet = StaleSet(keys = mutableKeys, namespaces = mutableNamespaces) + + val records = normalizedMutationEffects(staleSet) + + // Deduplicated by full identity pair, sorted namespace effects before key effects, + // then by namespace and canonical id. + assertEquals( + listOf( + MutationEffectRecord( + kind = MutationEffectRecordKind.NAMESPACE, + namespace = "aaa", + canonicalId = null, + ), + MutationEffectRecord( + kind = MutationEffectRecordKind.NAMESPACE, + namespace = "zzz", + canonicalId = null, + ), + MutationEffectRecord( + kind = MutationEffectRecordKind.KEY, + namespace = "mutations", + canonicalId = "alpha", + ), + MutationEffectRecord( + kind = MutationEffectRecordKind.KEY, + namespace = "mutations", + canonicalId = "zulu", + ), + ), + records, + ) + + // The carrier is a copy: later mutation of the consumer's sets cannot change it. + mutableKeys.clear() + mutableNamespaces.clear() + assertEquals(4, normalizedRecordCount(records)) + } + + @Test + fun equalInputs_produceStructurallyEqualEffects() = runTest { + val first = + StaleSet( + keys = setOf(MutationsTestKey("one"), MutationsTestKey("two")), + namespaces = setOf(StoreNamespace("shared")), + ) + val second = + StaleSet( + keys = setOf(MutationsTestKey("two"), MutationsTestKey("one")), + namespaces = setOf(StoreNamespace("shared")), + ) + + assertEquals(normalizedMutationEffects(first), normalizedMutationEffects(second)) + } + + @Test + fun throwingStalesFunction_poisonsAndStopsBeforeTransport() = runTest { + val stalesFailure = IllegalStateException("stales failed") + lateinit var effectful: MutatorRef + val registry = + mutatorRegistry { + effectful = + mutator( + id = "effectful", + version = 1, + codec = FixtureStringArgsCodec, + stales = { _, _ -> throw stalesFailure }, + ) { _, value -> MutationPresence.Present(value) } + } + val backend = FakeBackend() + val engine = MutationEngine(registry, backend, baseReader = { "base" }) + val key = MutationsTestKey("throwing-stales") + val mutationId = engine.mutate(key, effectful, "pending") + + engine.drain(key) + + assertEquals(emptyList(), backend.pushedValues) + assertEquals( + listOf(mutationId), + engine.pending(key).map(PendingIntent::mutationId), + ) + val poisoned = engine.poisoned.first() + assertEquals(mutationId, poisoned.mutationId) + assertSame(stalesFailure, poisoned.failure) + } + + @Test + fun capturedEffects_areSnapshotBeforeFirstPush_withoutExecution() = runTest { + lateinit var effectful: MutatorRef + val staleKey = MutationsTestKey("stale-target") + val registry = + mutatorRegistry { + effectful = + mutator( + id = "effectful", + version = 1, + codec = FixtureStringArgsCodec, + stales = { _, _ -> + StaleSet( + keys = setOf(staleKey), + namespaces = setOf(StoreNamespace("derived")), + ) + }, + ) { _, value -> MutationPresence.Present(value) } + } + val backend = FakeBackend() + val engine = MutationEngine(registry, backend, baseReader = { "base" }) + val key = MutationsTestKey("capture") + engine.bind(EffectsNoopWriteHandle) + val mutationId = engine.mutate(key, effectful, "pending") + + engine.drain(key) + + // The push happened, the intent retired, and the snapshot was captured before transport; + // no effect executed (a codec-less engine has no durable effect rows to execute). + assertEquals(listOf("pending"), backend.pushedValues) + assertEquals(emptyList(), engine.pending(key)) + assertEquals( + listOf( + MutationEffectRecord( + kind = MutationEffectRecordKind.NAMESPACE, + namespace = "derived", + canonicalId = null, + ), + MutationEffectRecord( + kind = MutationEffectRecordKind.KEY, + namespace = "mutations", + canonicalId = "stale-target", + ), + ), + engine.capturedEffectsSnapshot(mutationId), + ) + } +} + +private fun normalizedRecordCount(records: List): Int = records.size + +private object EffectsNoopWriteHandle : + org.mobilenativefoundation.store6.core.seam.StoreWriteHandle { + override suspend fun apply( + key: MutationsTestKey, + value: String, + ) = Unit + + override suspend fun markStale(key: MutationsTestKey) = Unit + + override suspend fun confirmFresh( + key: MutationsTestKey, + etag: String?, + ) = Unit +} + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = 25.seconds, testBody = testBody) diff --git a/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationEventSurfaceTest.kt b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationEventSurfaceTest.kt new file mode 100644 index 000000000..86383d46c --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationEventSurfaceTest.kt @@ -0,0 +1,260 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.mutations + +import app.cash.turbine.test +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.StoreMeta +import kotlin.reflect.KProperty1 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +class MutationEventSurfaceTest { + // The non-generic sealed algebra exposes the exact stable fields for both the + // intent-scoped and client-scoped variants. + @Test + fun eventAlgebra_exposesIntentAndClientScopedStableFields() = runTest { + val identity = MutationKeyIdentity(namespace = "mutations", canonicalId = "entity-1") + val failure = + sanitizedMutationFailure( + kind = MutationFailureKind.TRANSPORT, + detail = "transport", + message = "offline", + occurredAtEpochMillis = 10L, + ) + val serverMeta = EventSurfaceTestMeta(writtenAtEpochMillis = 41L, etag = "etag-server") + + val enqueued = + MutationEnqueued( + mutationId = "mutation-1", + identity = identity, + occurredAtEpochMillis = 1L, + clientSequence = 9L, + mutatorId = "append", + ) + val attempted = + MutationAttempted( + mutationId = "mutation-1", + identity = identity, + occurredAtEpochMillis = 2L, + generation = 1, + attempt = 1, + ) + val conflictObserved = + MutationConflictObserved( + mutationId = "mutation-1", + identity = identity, + occurredAtEpochMillis = 3L, + generation = 1, + serverMeta = serverMeta, + ) + val acknowledged = + MutationAcknowledged( + mutationId = "mutation-1", + identity = identity, + occurredAtEpochMillis = 4L, + generation = 1, + presence = MutationPresenceState.PRESENT, + ) + val adopted = + MutationAdopted( + mutationId = "mutation-1", + identity = identity, + occurredAtEpochMillis = 5L, + generation = 1, + presence = MutationPresenceState.ABSENT, + ) + val effectApplied = + MutationEffectApplied( + mutationId = "mutation-1", + identity = identity, + occurredAtEpochMillis = 6L, + generation = 1, + effectIndex = 0, + ) + val effectSkipped = + MutationEffectSkipped( + mutationId = "mutation-1", + identity = identity, + occurredAtEpochMillis = 7L, + generation = 1, + effectIndex = 1, + ) + val failed = + MutationFailed( + mutationId = "mutation-1", + identity = identity, + occurredAtEpochMillis = 8L, + generation = 1, + state = MutationPendingState.INFLIGHT, + failure = failure, + ) + val parked = + MutationParked( + mutationId = "mutation-1", + identity = identity, + occurredAtEpochMillis = 9L, + generation = 1, + failure = failure, + ) + val retired = + MutationRetired( + mutationId = "mutation-1", + identity = identity, + occurredAtEpochMillis = 10L, + generation = 1, + retiredThroughSequence = 9L, + ) + + val intentEvents: List = + listOf( + enqueued, attempted, conflictObserved, acknowledged, adopted, + effectApplied, effectSkipped, failed, parked, retired, + ) + intentEvents.forEach { event -> + assertEquals("mutation-1", event.mutationId) + assertSame(identity, event.identity) + } + assertEquals((1L..10L).toList(), intentEvents.map(MutationIntentEvent::occurredAtEpochMillis)) + + assertEquals(9L, enqueued.clientSequence) + assertEquals("append", enqueued.mutatorId) + assertEquals(1, attempted.generation) + assertEquals(1, attempted.attempt) + assertSame(serverMeta, assertIs(conflictObserved.serverMeta)) + assertEquals(MutationPresenceState.PRESENT, acknowledged.presence) + assertEquals(MutationPresenceState.ABSENT, adopted.presence) + assertEquals(0, effectApplied.effectIndex) + assertEquals(1, effectSkipped.effectIndex) + assertEquals(MutationPendingState.INFLIGHT, failed.state) + assertSame(failure, failed.failure) + assertSame(failure, parked.failure) + assertEquals(9L, retired.retiredThroughSequence) + + val checkpointConfirmed = + MutationCheckpointConfirmed( + occurredAtEpochMillis = 11L, + clientId = "client-1", + requestedThroughSequence = 9L, + confirmedThroughSequence = 9L, + ) + val checkpointFailed = + MutationCheckpointFailed( + occurredAtEpochMillis = 12L, + clientId = "client-1", + requestedThroughSequence = 9L, + failure = failure, + ) + assertEquals("client-1", checkpointConfirmed.clientId) + assertEquals(9L, checkpointConfirmed.requestedThroughSequence) + assertEquals(9L, checkpointConfirmed.confirmedThroughSequence) + assertEquals("client-1", checkpointFailed.clientId) + assertEquals(9L, checkpointFailed.requestedThroughSequence) + assertSame(failure, checkpointFailed.failure) + + // Sealed exhaustiveness over the event root needs exactly three branches. + val allEvents: List = intentEvents + checkpointConfirmed + checkpointFailed + val described = + allEvents.map { event -> + when (event) { + is MutationIntentEvent -> "intent" + is MutationCheckpointConfirmed -> "checkpoint-confirmed" + is MutationCheckpointFailed -> "checkpoint-failed" + } + } + assertEquals(List(10) { "intent" } + "checkpoint-confirmed" + "checkpoint-failed", described) + } + + // Checkpoint events are client-scoped and never fabricate a mutation identity. + @Test + fun checkpointEventsCannotBeCastToIntentEvents() = runTest { + val failure = + sanitizedMutationFailure( + kind = MutationFailureKind.TRANSPORT, + detail = "transport", + message = "offline", + occurredAtEpochMillis = 10L, + ) + val confirmed: MutationEvent = + MutationCheckpointConfirmed( + occurredAtEpochMillis = 11L, + clientId = "client-1", + requestedThroughSequence = 9L, + confirmedThroughSequence = 9L, + ) + val failed: MutationEvent = + MutationCheckpointFailed( + occurredAtEpochMillis = 12L, + clientId = "client-1", + requestedThroughSequence = 9L, + failure = failure, + ) + + assertFalse(confirmed is MutationIntentEvent) + assertFalse(failed is MutationIntentEvent) + assertNull(confirmed as? MutationIntentEvent) + assertNull(failed as? MutationIntentEvent) + } + + // The facade property is a read-only advisory SharedFlow backed by a non-blocking + // bounded bus; it is never a drain, acknowledgement, retry, or settlement protocol. + @Test + fun events_isReadOnlyAdvisorySharedFlow_notDrainProtocol() = runTest { + // Compile-level: the facade exposes a read-only SharedFlow property. + val property: + KProperty1, SharedFlow> = + MutationStore::events + assertEquals("events", property.name) + + val bus = MutationEventBus() + // The published view is read-only, not the mutable sink. + assertFalse(bus.events is MutableSharedFlow<*>) + + // Replay 0: history emitted before collection is never delivered to a new collector. + repeat(3) { index -> assertTrue(bus.tryEmit(enqueuedAt(index.toLong()))) } + bus.events.test { + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + + // DROP_OLDEST with extra capacity 64: advisory emission never blocks, suspends, or + // fails, even with no consumer — dropped telemetry never changes state. + repeat(200) { index -> assertTrue(bus.tryEmit(enqueuedAt(index.toLong()))) } + } +} + +private class EventSurfaceTestMeta( + override val writtenAtEpochMillis: Long, + override val etag: String?, +) : StoreMeta + +private fun enqueuedAt(sequence: Long): MutationEnqueued = + MutationEnqueued( + mutationId = "mutation-$sequence", + identity = MutationKeyIdentity(namespace = "mutations", canonicalId = "entity-1"), + occurredAtEpochMillis = sequence, + clientSequence = sequence, + mutatorId = "append", + ) + +// Turbine's 3s default would nest inside the 25s shadow. Raising the Turbine deadline above +// the shadow makes runTest the only effective timeout. +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds // strictly > TEST_TIMEOUT: the shadow must fire first + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } diff --git a/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationEventsTest.kt b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationEventsTest.kt new file mode 100644 index 000000000..6f803e0a7 --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationEventsTest.kt @@ -0,0 +1,707 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, + kotlinx.coroutines.ExperimentalCoroutinesApi::class, +) + +package org.mobilenativefoundation.store6.mutations + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.StoreException +import org.mobilenativefoundation.store6.core.StoreMeta +import org.mobilenativefoundation.store6.core.seam.StoreResults +import org.mobilenativefoundation.store6.core.seam.StoreWriteHandle +import org.mobilenativefoundation.store6.mutations.storage.InMemoryMutationJournalStorage +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionPhase +import org.mobilenativefoundation.store6.mutations.storage.MutationJournalStorage +import org.mobilenativefoundation.store6.testing.TestWallClock +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +class MutationEventsTest { + @Test + fun enqueueAttemptConflictAckAdoptEffectAndRetire_emitInCausalOrder() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = EventMutations() + val backend = FakeBackend() + val handle = EventWriteHandle() + val source = MutationsTestKey("events-causal-source") + val target = MutationsTestKey("events-causal-target") + backend.pushBehavior = { _, value -> + if (backend.receivedPushes.size == 1) { + throw eventConflict(EventMeta(41L, "events-conflict")) + } + MutationPresentAck( + authoritative = value, + etag = "events-acked", + canonicalKey = target, + ) + } + val engine = + openEventEngine( + storage = storage, + mutations = mutations, + backend = backend, + handle = handle, + conflicts = + MutationConflictRegistration( + precondition = null, + merge = { _, mine, _ -> MutationConflictResolution.Retry(mine) }, + ), + ) + val observed = collectEvents(engine) + + val mutationId = engine.mutate(source, mutations.set, "mine") + engine.drain(source) + engine.drain(source) + runCurrent() + + assertEquals( + listOf( + "enqueued", + "attempted", + "conflict", + "attempted", + "acknowledged", + "adopted", + "effect-applied", + "retired", + ), + observed.map(MutationEvent::eventName), + ) + assertEventTimes(observed) + val intentEvents = observed.filterIsInstance() + assertEquals(8, intentEvents.size) + intentEvents.forEach { event -> + assertEquals(mutationId, event.mutationId) + assertIdentity(source.identity(), event.identity) + } + val enqueued = assertIs(intentEvents[0]) + assertEquals(1L, enqueued.clientSequence) + assertEquals("events-set", enqueued.mutatorId) + val attempts = intentEvents.filterIsInstance() + assertEquals(listOf(1 to 1, 2 to 1), attempts.map { it.generation to it.attempt }) + val conflict = assertIs(intentEvents[2]) + assertEquals(1, conflict.generation) + assertEquals(41L, conflict.serverMeta?.writtenAtEpochMillis) + assertEquals("events-conflict", conflict.serverMeta?.etag) + val acknowledged = assertIs(intentEvents[4]) + assertEquals(2, acknowledged.generation) + assertEquals(MutationPresenceState.PRESENT, acknowledged.presence) + val adopted = assertIs(intentEvents[5]) + assertEquals(2, adopted.generation) + assertEquals(MutationPresenceState.PRESENT, adopted.presence) + val effect = assertIs(intentEvents[6]) + assertEquals(2, effect.generation) + assertEquals(0, effect.effectIndex) + val retired = assertIs(intentEvents[7]) + assertEquals(2, retired.generation) + assertEquals(1L, retired.retiredThroughSequence) + } + + @Test + fun activeFailurePrecedesLaterRetryEvent() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = EventMutations() + val backend = FakeBackend() + var failNextPush = true + backend.pushBehavior = { _, value -> + if (failNextPush) { + failNextPush = false + throw IllegalStateException("events transport offline") + } + MutationPresentAck(value, "events-retry-acked", null) + } + val engine = openEventEngine(storage, mutations, backend) + val observed = collectEvents(engine) + val key = MutationsTestKey("events-active-failure") + + val mutationId = engine.mutate(key, mutations.set, "mine") + engine.drain(key) + runCurrent() + + engine.drain(key) + runCurrent() + + assertEquals( + listOf( + "enqueued", + "attempted", + "failed", + "attempted", + "acknowledged", + "adopted", + "effect-applied", + "retired", + ), + observed.map(MutationEvent::eventName), + ) + assertEventTimes(observed) + val failure = assertIs(observed[2]) + assertEquals(mutationId, failure.mutationId) + assertIdentity(key.identity(), failure.identity) + assertEquals(1, failure.generation) + assertEquals(MutationPendingState.PENDING, failure.state) + assertEquals(MutationFailureKind.TRANSPORT, failure.failure.kind) + assertEquals("push-failed", failure.failure.detail) + assertEquals("events transport offline", failure.failure.message) + assertEquals(EVENT_TIME, failure.failure.occurredAtEpochMillis) + observed.filterIsInstance().forEach { event -> + assertEquals(mutationId, event.mutationId) + assertIdentity(key.identity(), event.identity) + } + val attempts = observed.filterIsInstance() + assertEquals(listOf(1, 1), attempts.map { it.generation }) + assertEquals(listOf(1, 2), attempts.map { it.attempt }) + assertTrue(observed.indexOf(failure) < observed.indexOf(attempts.last())) + val acknowledged = assertIs(observed[4]) + assertEquals(1, acknowledged.generation) + assertEquals(MutationPresenceState.PRESENT, acknowledged.presence) + val adopted = assertIs(observed[5]) + assertEquals(1, adopted.generation) + assertEquals(MutationPresenceState.PRESENT, adopted.presence) + val effect = assertIs(observed[6]) + assertEquals(1, effect.generation) + assertEquals(0, effect.effectIndex) + val retired = assertIs(observed[7]) + assertEquals(1, retired.generation) + assertEquals(1L, retired.retiredThroughSequence) + } + + @Test + fun appliedAndSkippedEffectsHaveDistinctEvents() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = EventMutations() + val backend = FakeBackend() + val appliedKey = MutationsTestKey("events-effect-applied") + val skippedKey = MutationsTestKey("events-effect-skipped") + backend.pushBehavior = { key, value -> + if (key.identity() == skippedKey.identity()) { + throw eventConflict(EventMeta(52L, "server-wins")) + } + MutationPresentAck(value, "effect-applied", null) + } + val engine = openEventEngine(storage, mutations, backend) + val observed = collectEvents(engine) + + val appliedId = engine.mutate(appliedKey, mutations.set, "applied") + engine.drain(appliedKey) + val skippedId = engine.mutate(skippedKey, mutations.set, "skipped") + engine.drain(skippedKey) + engine.drain(skippedKey) + runCurrent() + + assertEquals( + listOf( + "enqueued", + "attempted", + "acknowledged", + "adopted", + "effect-applied", + "retired", + "enqueued", + "attempted", + "conflict", + "effect-skipped", + "retired", + ), + observed.map(MutationEvent::eventName), + ) + assertEventTimes(observed) + observed.subList(0, 6).forEach { event -> + val intent = assertIs(event) + assertEquals(appliedId, intent.mutationId) + assertIdentity(appliedKey.identity(), intent.identity) + } + observed.subList(6, 11).forEach { event -> + val intent = assertIs(event) + assertEquals(skippedId, intent.mutationId) + assertIdentity(skippedKey.identity(), intent.identity) + } + val applied = assertIs(observed[4]) + assertEquals(appliedId, applied.mutationId) + assertIdentity(appliedKey.identity(), applied.identity) + assertEquals(0, applied.effectIndex) + assertEquals(1, applied.generation) + val skipped = assertIs(observed[9]) + assertEquals(skippedId, skipped.mutationId) + assertIdentity(skippedKey.identity(), skipped.identity) + assertEquals(0, skipped.effectIndex) + assertEquals(1, skipped.generation) + val conflict = assertIs(observed[8]) + assertEquals(skippedId, conflict.mutationId) + assertIdentity(skippedKey.identity(), conflict.identity) + assertEquals(1, conflict.generation) + val appliedRetired = assertIs(observed[5]) + assertEquals(appliedId, appliedRetired.mutationId) + assertEquals(1L, appliedRetired.retiredThroughSequence) + val skippedRetired = assertIs(observed[10]) + assertEquals(skippedId, skippedRetired.mutationId) + assertEquals(2L, skippedRetired.retiredThroughSequence) + } + + @Test + fun parkEmitsNormalizedFailureWithoutRawThrowable() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = EventMutations() + val engine = openEventEngine(storage, mutations, FakeBackend()) + val observed = collectEvents(engine) + val key = MutationsTestKey("events-park") + + val mutationId = engine.mutate(key, mutations.park, "ignored") + engine.drain(key) + runCurrent() + + assertEquals(listOf("enqueued", "parked"), observed.map(MutationEvent::eventName)) + assertEventTimes(observed) + observed.forEach { event -> + val intent = assertIs(event) + assertEquals(mutationId, intent.mutationId) + assertIdentity(key.identity(), intent.identity) + } + val parked = assertIs(observed[1]) + assertEquals(mutationId, parked.mutationId) + assertIdentity(key.identity(), parked.identity) + assertEquals(0, parked.generation) + assertEquals(MutationFailureKind.PROJECTION, parked.failure.kind) + assertEquals(DRAIN_FAILURE_DETAIL_PROJECTION_THROW, parked.failure.detail) + assertEquals("events projection exploded", parked.failure.message) + assertEquals(EVENT_TIME, parked.failure.occurredAtEpochMillis) + assertFalse(parked.failure.message.contains("raw.Stack")) + assertFalse(parked.failure.message.contains("IllegalStateException")) + assertTrue(observed.none { it is MutationFailed }) + val deadLetter = engine.deadLetters().single() + assertEquals(mutationId, deadLetter.mutationId) + assertEquals(parked.generation, deadLetter.generation) + assertEquals(parked.failure.kind, deadLetter.failure.kind) + assertEquals(parked.failure.detail, deadLetter.failure.detail) + assertEquals(parked.failure.message, deadLetter.failure.message) + assertEquals( + parked.failure.occurredAtEpochMillis, + deadLetter.failure.occurredAtEpochMillis, + ) + } + + @Test + fun checkpointFailureHasClientScopeWithoutFabricatedMutationIdentity() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = EventMutations() + val backend = FakeBackend() + val engine = openEventEngine(storage, mutations, backend) + backend.retireBehavior = { + throw IllegalStateException("events checkpoint unavailable") + } + val observed = collectEvents(engine) + val key = MutationsTestKey("events-checkpoint-failure") + + val mutationId = engine.mutate(key, mutations.set, "mine") + engine.drain(key) + runCurrent() + + assertEquals( + listOf( + "enqueued", + "attempted", + "acknowledged", + "adopted", + "effect-applied", + "retired", + "checkpoint-failed", + ), + observed.map(MutationEvent::eventName), + ) + assertEventTimes(observed) + observed.subList(0, 6).forEach { event -> + val intent = assertIs(event) + assertEquals(mutationId, intent.mutationId) + assertIdentity(key.identity(), intent.identity) + } + val failed = assertIs(observed[6]) + val checkpointEvent: MutationEvent = failed + assertFalse(checkpointEvent is MutationIntentEvent) + assertEquals(EVENT_CLIENT_ID, failed.clientId) + assertEquals(1L, failed.requestedThroughSequence) + assertEquals(MutationFailureKind.TRANSPORT, failed.failure.kind) + assertEquals("retire-failed", failed.failure.detail) + assertEquals("events checkpoint unavailable", failed.failure.message) + assertEquals(EVENT_TIME, failed.failure.occurredAtEpochMillis) + assertTrue(storage.transaction { it.failures(EVENT_CLIENT_ID).isEmpty() }) + assertTrue(observed.none { it is MutationFailed }) + val client = storage.transaction { requireNotNull(it.client(EVENT_CLIENT_ID)) } + assertEquals(1L, client.retiredThroughSequence) + assertEquals(0L, client.serverConfirmedRetiredThroughSequence) + } + + @Test + fun restartDoesNotDuplicateCompletedLifecycleEvents() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = EventMutations() + val backend = FakeBackend() + val key = MutationsTestKey("events-completed-restart") + val first = openEventEngine(storage, mutations, backend) + val firstEvents = collectEvents(first) + + val mutationId = first.mutate(key, mutations.set, "complete") + first.drain(key) + runCurrent() + + val reopened = openEventEngine(storage, mutations, backend, EventWriteHandle()) + val restartedEvents = collectEvents(reopened) + reopened.ensureHydrated() + runCurrent() + assertEquals( + emptyList(), + restartedEvents.map(MutationEvent::eventName), + "hydration must emit no lifecycle history", + ) + + reopened.drain(key) + reopened.drain() + runCurrent() + assertEquals( + listOf( + "enqueued", + "attempted", + "acknowledged", + "adopted", + "effect-applied", + "retired", + ), + firstEvents.map(MutationEvent::eventName), + ) + assertEventTimes(firstEvents) + firstEvents.filterIsInstance().forEach { event -> + assertEquals(mutationId, event.mutationId) + assertIdentity(key.identity(), event.identity) + } + assertEquals( + emptyList(), + restartedEvents.map(MutationEvent::eventName), + "completed transitions must not re-emit after restart", + ) + assertEquals(1, backend.receivedPushes.size) + assertEquals( + MutationExecutionPhase.RETIRED, + storage.transaction { it.executions(EVENT_CLIENT_ID).single().phase }, + ) + } + + @Test + fun restartResumedTransitions_emitTheirLifecycleEventsNormally() = runTest { + val delegate = InMemoryMutationJournalStorage() + val storage = FailPointJournalStorage(delegate) + val mutations = EventMutations() + val backend = FakeBackend() + val key = MutationsTestKey("events-acked-restart") + val first = openEventEngine(storage, mutations, backend) + val firstEvents = collectEvents(first) + val mutationId = first.mutate(key, mutations.set, "resume") + storage.armKillAfterCommit(JournalFailPointBoundary.ACK_RECEIPT) + + val death = assertFailsWith { first.drain(key) } + assertTrue(death.committed) + runCurrent() + assertEquals(1, backend.receivedPushes.size) + assertEquals( + MutationExecutionPhase.ACKED, + storage.transaction { it.executions(EVENT_CLIENT_ID).single().phase }, + ) + + val reopened = openEventEngine(storage, mutations, backend, EventWriteHandle()) + val observed = collectEvents(reopened) + reopened.ensureHydrated() + runCurrent() + assertEquals( + emptyList(), + observed.map(MutationEvent::eventName), + "ACKED hydration must not emit", + ) + + reopened.drain(key) + runCurrent() + + assertEquals( + listOf("enqueued", "attempted"), + firstEvents.map(MutationEvent::eventName), + ) + assertEventTimes(firstEvents) + firstEvents.filterIsInstance().forEach { event -> + assertEquals(mutationId, event.mutationId) + assertIdentity(key.identity(), event.identity) + } + val firstAttempt = assertIs(firstEvents[1]) + assertEquals(1, firstAttempt.generation) + assertEquals(1, firstAttempt.attempt) + assertEquals( + listOf("adopted", "effect-applied", "retired"), + observed.map(MutationEvent::eventName), + ) + assertEventTimes(observed) + observed.filterIsInstance().forEach { event -> + assertEquals(mutationId, event.mutationId) + assertIdentity(key.identity(), event.identity) + } + val adopted = assertIs(observed[0]) + assertEquals(1, adopted.generation) + assertEquals(MutationPresenceState.PRESENT, adopted.presence) + val effect = assertIs(observed[1]) + assertEquals(1, effect.generation) + assertEquals(0, effect.effectIndex) + val retired = assertIs(observed[2]) + assertEquals(1, retired.generation) + assertEquals(1L, retired.retiredThroughSequence) + assertEquals(1, backend.receivedPushes.size) + assertEquals( + MutationExecutionPhase.RETIRED, + storage.transaction { it.executions(EVENT_CLIENT_ID).single().phase }, + ) + } + + @Test + fun replayedInflightSend_reEmitsSameAttemptOrdinal() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = EventMutations() + val cancellingBackend = FakeBackend() + cancellingBackend.pushBehavior = { _, _ -> + throw CancellationException("events transport cancelled") + } + val key = MutationsTestKey("events-inflight-replay") + val first = openEventEngine(storage, mutations, cancellingBackend) + val firstEvents = collectEvents(first) + val mutationId = first.mutate(key, mutations.set, "replay") + + assertFailsWith { first.drain(key) } + runCurrent() + + assertEquals(MutationPendingState.INFLIGHT, first.pending(key).single().state) + assertEquals(0, first.pending(key).single().attempt) + + val replayBackend = FakeBackend() + val reopened = openEventEngine(storage, mutations, replayBackend, EventWriteHandle()) + val replayEvents = collectEvents(reopened) + reopened.ensureHydrated() + runCurrent() + assertEquals( + emptyList(), + replayEvents.map(MutationEvent::eventName), + "INFLIGHT hydration must not emit", + ) + + reopened.drain(key) + runCurrent() + + assertEquals(listOf("enqueued", "attempted"), firstEvents.map(MutationEvent::eventName)) + assertEventTimes(firstEvents) + val firstAttempt = assertIs(firstEvents.last()) + assertEquals(mutationId, firstAttempt.mutationId) + assertIdentity(key.identity(), firstAttempt.identity) + assertEquals(1, firstAttempt.generation) + assertEquals(1, firstAttempt.attempt) + assertEquals( + listOf("attempted", "acknowledged", "adopted", "effect-applied", "retired"), + replayEvents.map(MutationEvent::eventName), + ) + assertEventTimes(replayEvents) + replayEvents.forEach { event -> + val intent = assertIs(event) + assertEquals(mutationId, intent.mutationId) + assertIdentity(key.identity(), intent.identity) + } + val replayAttempt = assertIs(replayEvents.first()) + assertEquals(mutationId, replayAttempt.mutationId) + assertEquals(firstAttempt.generation, replayAttempt.generation) + assertEquals(firstAttempt.attempt, replayAttempt.attempt) + assertIdentity(firstAttempt.identity, replayAttempt.identity) + assertEquals(1, replayBackend.receivedPushes.size) + val firstPush = cancellingBackend.receivedPushes.single() + val replayPush = replayBackend.receivedPushes.single() + assertEquals(firstPush.clientId, replayPush.clientId) + assertEquals(firstPush.clientSequence, replayPush.clientSequence) + assertEquals(firstPush.retiredThroughSequence, replayPush.retiredThroughSequence) + assertEquals(firstPush.mutationId, replayPush.mutationId) + assertEquals(firstPush.generation, replayPush.generation) + assertEquals(firstPush.idempotencyKey, replayPush.idempotencyKey) + assertEquals(firstPush.valueCodecVersion, replayPush.valueCodecVersion) + assertIdentity(firstPush.identity, replayPush.identity) + val acknowledged = assertIs(replayEvents[1]) + assertEquals(1, acknowledged.generation) + assertEquals(MutationPresenceState.PRESENT, acknowledged.presence) + val adopted = assertIs(replayEvents[2]) + assertEquals(1, adopted.generation) + assertEquals(MutationPresenceState.PRESENT, adopted.presence) + val effect = assertIs(replayEvents[3]) + assertEquals(1, effect.generation) + assertEquals(0, effect.effectIndex) + val retired = assertIs(replayEvents[4]) + assertEquals(1, retired.generation) + assertEquals(1L, retired.retiredThroughSequence) + } +} + +private const val EVENT_CLIENT_ID: String = "client-0" +private const val EVENT_TIME: Long = 1_000L + +private class EventMutations { + lateinit var set: MutatorRef + lateinit var park: MutatorRef + + val registry: MutatorRegistry = + mutatorRegistry { + set = + mutator( + id = "events-set", + version = 1, + codec = FixtureStringArgsCodec, + stales = { key, _ -> + StaleSet(keys = setOf(key), namespaces = emptySet()) + }, + ) { _, value -> MutationPresence.Present(value) } + park = + mutator( + id = "events-park", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { _, _ -> + throw IllegalStateException("events projection exploded\nat raw.Stack") + } + } +} + +private class EventWriteHandle : StoreWriteHandle { + private val values = mutableMapOf() + val staleKeys: MutableList = mutableListOf() + + fun read(key: MutationsTestKey): String? = values[key.identity()] ?: "base" + + fun clear(key: MutationsTestKey) { + values.remove(key.identity()) + } + + override suspend fun apply(key: MutationsTestKey, value: String) { + values[key.identity()] = value + } + + override suspend fun markStale(key: MutationsTestKey) { + staleKeys += key.identity() + } + + override suspend fun confirmFresh(key: MutationsTestKey, etag: String?) = Unit +} + +private fun openEventEngine( + storage: MutationJournalStorage, + mutations: EventMutations, + backend: FakeBackend, + handle: EventWriteHandle = EventWriteHandle(), + conflicts: MutationConflictRegistration? = null, + clock: TestWallClock = TestWallClock(EVENT_TIME), +): MutationEngine { + backend.retireBehavior = { MutationRetirementAck(confirmedThroughSequence = 0L) } + val journal = + StorageBackedMutationJournal( + storage = storage, + registrations = mutations.registry.registrations, + clientId = EVENT_CLIENT_ID, + hydrateOnFirstUse = true, + ) + return MutationEngine( + registry = mutations.registry, + server = backend, + journal = journal, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + conflicts = conflicts, + baseReader = handle::read, + absentAdoption = { key -> handle.clear(key) }, + wallClock = clock, + clientId = EVENT_CLIENT_ID, + ).also { it.bind(handle) } +} + +private fun TestScope.collectEvents( + engine: MutationEngine, +): MutableList { + val observed = mutableListOf() + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + engine.eventBus.events.collect { event -> observed += event } + } + return observed +} + +private fun MutationEvent.eventName(): String = + when (this) { + is MutationEnqueued -> "enqueued" + is MutationAttempted -> "attempted" + is MutationConflictObserved -> "conflict" + is MutationAcknowledged -> "acknowledged" + is MutationAdopted -> "adopted" + is MutationEffectApplied -> "effect-applied" + is MutationEffectSkipped -> "effect-skipped" + is MutationFailed -> "failed" + is MutationParked -> "parked" + is MutationRetired -> "retired" + is MutationCheckpointConfirmed -> "checkpoint-confirmed" + is MutationCheckpointFailed -> "checkpoint-failed" + } + +private fun assertIdentity( + expected: KeyIdentity, + actual: MutationKeyIdentity, +) { + assertEquals(expected.namespace, actual.namespace) + assertEquals(expected.canonicalId, actual.canonicalId) +} + +private fun assertIdentity( + expected: MutationKeyIdentity, + actual: MutationKeyIdentity, +) { + assertEquals(expected.namespace, actual.namespace) + assertEquals(expected.canonicalId, actual.canonicalId) +} + +private fun assertEventTimes(events: List) { + events.forEach { event -> + assertEquals(EVENT_TIME, event.occurredAtEpochMillis) + val failure = + when (event) { + is MutationFailed -> event.failure + is MutationParked -> event.failure + is MutationCheckpointFailed -> event.failure + else -> null + } + if (failure != null) { + assertEquals(EVENT_TIME, failure.occurredAtEpochMillis) + assertTrue(failure.detail.encodeToByteArray().size <= MUTATION_FAILURE_DETAIL_MAX_UTF8_BYTES) + assertTrue(failure.message.encodeToByteArray().size <= MUTATION_FAILURE_MESSAGE_MAX_UTF8_BYTES) + } + } +} + +private fun eventConflict(meta: StoreMeta?): StoreException = + StoreResults.exception( + StoreResults.conflict(meta, "events conflict"), + IllegalStateException("events conflict cause"), + ) + +private class EventMeta( + override val writtenAtEpochMillis: Long, + override val etag: String?, +) : StoreMeta + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = 25.seconds, testBody = testBody) diff --git a/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspectionTest.kt b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspectionTest.kt new file mode 100644 index 000000000..9a49bf469 --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspectionTest.kt @@ -0,0 +1,423 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, +) + +package org.mobilenativefoundation.store6.mutations + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.seam.StoreWriteHandle +import org.mobilenativefoundation.store6.testing.TestWallClock +import kotlin.reflect.KProperty1 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +/** + * Truthful pending/pendingWrites/deadLetters snapshots and the normalized failure carrier's + * sanitization contract. These tests exercise inspection shapes only and never fake the durable + * scheduler: `REFRESHING` and `APPLYING_EFFECTS` have no producer on the in-memory path and are + * proven through the frozen total mapping; the phases the in-memory pass truthfully produces are + * observed live. Restart hydration is covered by `MutationJournalContractTest`; parking, + * declined/parked scheduling, and dead-letter production by `MutationDrainParkingTest`. + */ +class MutationInspectionTest { + @Test + fun pendingStates_coverEveryActiveExecutionPhase() = runTest { + // The total mapping: every nonterminal active phase maps to exactly + // one public state; PARKED maps only to deadLetters() and RETIRED to neither API. + assertEquals( + listOf( + MutationExecutionPhase.UNPREPARED to MutationPendingState.PENDING, + MutationExecutionPhase.READY to MutationPendingState.PENDING, + MutationExecutionPhase.INFLIGHT to MutationPendingState.INFLIGHT, + MutationExecutionPhase.REFRESH_REQUIRED to MutationPendingState.REFRESHING, + MutationExecutionPhase.ACKED to MutationPendingState.ADOPTING, + MutationExecutionPhase.EFFECTS_PENDING to MutationPendingState.APPLYING_EFFECTS, + MutationExecutionPhase.PARKED to null, + MutationExecutionPhase.RETIRED to null, + ), + MutationExecutionPhase.entries.map { phase -> phase to phase.toPendingStateOrNull() }, + ) + + // Live: the phases the in-memory foreground pass truthfully produces are visible through + // pending(): PENDING at enqueue, INFLIGHT while the push is suspended, ADOPTING inside + // the write-handle adoption window, and removal after retirement. + val mutation = InspectionRenameMutation() + val pushEntered = CompletableDeferred() + val releasePush = CompletableDeferred() + val backend = + FakeBackend().apply { + pushBehavior = { _, value -> + pushEntered.complete(Unit) + releasePush.await() + MutationPresentAck(authoritative = value, etag = "etag", canonicalKey = null) + } + } + val engine = + MutationEngine( + registry = mutation.registry, + server = backend, + keyResolver = MutationsTestKeyResolver, + baseReader = { "base" }, + ) + val observedDuringAdoption = mutableListOf() + val probeHandle = + object : StoreWriteHandle { + override suspend fun apply( + key: MutationsTestKey, + value: String, + ) { + observedDuringAdoption += engine.pending(key).single().state + } + + override suspend fun markStale(key: MutationsTestKey) = Unit + + override suspend fun confirmFresh( + key: MutationsTestKey, + etag: String?, + ) = Unit + } + engine.bind(probeHandle) + val key = MutationsTestKey("phase-walk") + engine.mutate(key, mutation.ref, "pending-value") + + assertEquals(MutationPendingState.PENDING, engine.pending(key).single().state) + + val drainPass = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.drain(key) + } + pushEntered.await() + assertEquals(MutationPendingState.INFLIGHT, engine.pending(key).single().state) + + releasePush.complete(Unit) + drainPass.await() + + assertEquals(listOf(MutationPendingState.ADOPTING), observedDuringAdoption) + // Retired appears in neither inspection API. + assertEquals(emptyList(), engine.pending(key)) + assertEquals(emptyList(), engine.pendingWrites()) + assertEquals(emptyList(), engine.deadLetters()) + } + + @Test + fun pendingWrites_returnsMultiKeyIdentitySnapshots() = runTest { + val mutation = InspectionRenameMutation() + val backend = FakeBackend() + val clock = TestWallClock(startEpochMillis = 1_000L) + val firstKey = MutationsTestKey("snapshot-a") + val secondKey = MutationsTestKey("snapshot-b") + val users = + mutationStore( + registry = mutation.registry, + server = backend, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + ) { + fetcher { backend.load(it) } + wallClock(clock) + } + + try { + val firstId = users.mutate(firstKey, mutation.ref, "one") + clock.setEpochMillis(2_000L) + val secondId = users.mutate(secondKey, mutation.ref, "two") + clock.setEpochMillis(3_000L) + val thirdId = users.mutate(firstKey, mutation.ref, "three") + + val rows = users.pendingWrites() + + // All identities, durable client-sequence order, real enqueue stamps. + assertEquals(listOf(firstId, secondId, thirdId), rows.map(PendingIntent::mutationId)) + assertEquals( + listOf("snapshot-a", "snapshot-b", "snapshot-a"), + rows.map(PendingIntent::canonicalId), + ) + assertTrue(rows.all { row -> row.namespace == "mutations" }) + assertEquals(listOf(1_000L, 2_000L, 3_000L), rows.map(PendingIntent::createdAtEpochMillis)) + assertTrue(rows.all { row -> row.state == MutationPendingState.PENDING }) + assertTrue(rows.all { row -> row.attempt == 0 }) + } finally { + users.close() + } + } + + @Test + fun pendingAndDeadLetters_excludeRetiredHistory() = runTest { + val mutation = InspectionRenameMutation() + val backend = FakeBackend() + val retiredKey = MutationsTestKey("history-retired") + val activeKey = MutationsTestKey("history-active") + val users = + mutationStore( + registry = mutation.registry, + server = backend, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + ) { + fetcher { backend.load(it) } + } + + try { + val retiredId = users.mutate(retiredKey, mutation.ref, "retired-value") + users.drain(retiredKey) + val activeId = users.mutate(activeKey, mutation.ref, "active-value") + + assertEquals(emptyList(), users.pending(retiredKey)) + assertEquals(listOf(activeId), users.pendingWrites().map(PendingIntent::mutationId)) + assertEquals(emptyList(), users.deadLetters()) + assertFalse(users.pendingWrites().any { row -> row.mutationId == retiredId }) + } finally { + users.close() + } + } + + @Test + fun deadLettersContainOnlyParkedExecutions() = runTest { + val mutation = InspectionRenameMutation() + val backend = FakeBackend() + val engine = + MutationEngine( + registry = mutation.registry, + server = backend, + keyResolver = MutationKeyResolver { throw IllegalStateException("unresolvable") }, + baseReader = { "base" }, + ) + engine.bind(InspectionNoopHandle) + val key = MutationsTestKey("never-parked-at-021") + val mutationId = engine.mutate(key, mutation.ref, "still-pending") + engine.clearLiveKeyCache() + + engine.drain() + + // Dead letters contain only durably PARKED executions. A codec-less engine records the + // normalized in-memory carrier but never parks, so the failed identity stays pending and + // the dead-letter list stays empty. + assertEquals(emptyList(), engine.deadLetters()) + assertEquals( + listOf(mutationId), + engine.pending(key).map(PendingIntent::mutationId), + ) + assertEquals(1, engine.drainFailuresForInspection().size) + } + + @Test + fun failureSanitizesControlsAndTruncatesUtf8AtCodePointBoundaries() = runTest { + // Stack-trace lines and ISO controls never survive; the first line alone is kept. + val controlled = + sanitizedMutationFailure( + kind = MutationFailureKind.IDENTITY, + detail = "a\tbc\nat frame.one(File.kt:1)", + message = "first line\r\nsecond line", + occurredAtEpochMillis = 5L, + ) + assertEquals("abc", controlled.detail) + assertEquals("first line", controlled.message) + + // Two-byte code points: 64 alphas are exactly the 128-byte detail budget; one more + // character is dropped whole. + val twoByte = + sanitizedMutationFailure( + kind = MutationFailureKind.IDENTITY, + detail = "α".repeat(64) + "x", + message = "", + occurredAtEpochMillis = 5L, + ) + assertEquals("α".repeat(64), twoByte.detail) + assertEquals(128, twoByte.detail.encodeToByteArray().size) + + // Three-byte code points: 43 euro signs are 129 bytes; truncation keeps 42 whole + // characters (126 bytes), never a partial sequence. + val threeByte = + sanitizedMutationFailure( + kind = MutationFailureKind.IDENTITY, + detail = "€".repeat(43), + message = "", + occurredAtEpochMillis = 5L, + ) + assertEquals("€".repeat(42), threeByte.detail) + assertEquals(126, threeByte.detail.encodeToByteArray().size) + + // Four-byte code points: a surrogate pair straddling the boundary is dropped whole, so + // no lone surrogate can survive truncation. + val musicalG = "𝄞" + val surrogate = + sanitizedMutationFailure( + kind = MutationFailureKind.IDENTITY, + detail = "a".repeat(126) + musicalG, + message = "", + occurredAtEpochMillis = 5L, + ) + assertEquals("a".repeat(126), surrogate.detail) + assertFalse(surrogate.detail.last().isHighSurrogate()) + + // The message budget is 1,024 bytes with the same code-point rule. + val wideMessage = + sanitizedMutationFailure( + kind = MutationFailureKind.IDENTITY, + detail = "", + message = "б".repeat(513), + occurredAtEpochMillis = 5L, + ) + assertEquals("б".repeat(512), wideMessage.message) + assertEquals(1_024, wideMessage.message.encodeToByteArray().size) + } + + @Test + fun failureCarrierContainsNoThrowableOrStoreError() = runTest { + // Compile-level: the carrier exposes only an append-only kind, two sanitized strings, + // and an epoch stamp. No property can hold a Throwable or StoreError. + val kind: KProperty1 = MutationFailure::kind + val detail: KProperty1 = MutationFailure::detail + val message: KProperty1 = MutationFailure::message + val occurredAt: KProperty1 = MutationFailure::occurredAtEpochMillis + assertEquals( + listOf("kind", "detail", "message", "occurredAtEpochMillis"), + listOf(kind.name, detail.name, message.name, occurredAt.name), + ) + + // Engine path: a resolver throw with a cause chain normalizes to sanitized strings and a + // clock stamp; nothing of the original Throwable — class, frames, or cause — survives. + val mutation = InspectionRenameMutation() + val backend = FakeBackend() + val engine = + MutationEngine( + registry = mutation.registry, + server = backend, + keyResolver = + MutationKeyResolver { + throw IllegalStateException( + "lookup failed", + IllegalArgumentException("root cause"), + ) + }, + baseReader = { "base" }, + wallClock = TestWallClock(startEpochMillis = 9_000L), + ) + engine.bind(InspectionNoopHandle) + val key = MutationsTestKey("carrier-shape") + engine.mutate(key, mutation.ref, "value") + engine.clearLiveKeyCache() + + engine.drain() + + val failure = engine.drainFailuresForInspection().single() + assertEquals(MutationFailureKind.IDENTITY, failure.kind) + // The exact thrown first line and nothing else: no exception class name, no cause text. + assertEquals("lookup failed", failure.message) + assertFalse(failure.message.contains("IllegalStateException")) + assertFalse(failure.message.contains("root cause")) + assertEquals(9_000L, failure.occurredAtEpochMillis) + } + + @Test + fun poisonRemainsExactThrowableBesideNormalizedCarrier() = runTest { + val projectionFailure = IllegalStateException("hostile projection") + lateinit var hostile: MutatorRef + val registry = + mutatorRegistry { + hostile = + mutator( + id = "hostile", + version = 1, + codec = inertArgsCodec(), + stales = noStales(), + ) { _, _ -> throw projectionFailure } + } + val backend = FakeBackend() + val engine = + MutationEngine( + registry = registry, + server = backend, + keyResolver = MutationsTestKeyResolver, + baseReader = { "base" }, + ) + engine.bind(InspectionNoopHandle) + val key = MutationsTestKey("poison-beside-carrier") + val mutationId = engine.mutate(key, hostile, Unit) + + engine.drain(key) + + // Poison is the ephemeral exact-Throwable flow; it is not a drain failure carrier, + // and the projection throw records none. + val poisoned = engine.poisoned.replayCache.single() + assertEquals(mutationId, poisoned.mutationId) + assertSame(projectionFailure, poisoned.failure) + assertEquals(emptyList(), engine.drainFailuresForInspection()) + assertEquals(emptyList(), backend.pushedValues) + } + + @Test + fun failedPush_recordsCompletedAttemptFact() = runTest { + val mutation = InspectionRenameMutation() + val backend = FakeBackend() + val key = MutationsTestKey("attempt-facts") + val users = + mutationStore( + registry = mutation.registry, + server = backend, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + ) { + fetcher { backend.load(it) } + } + + try { + assertEquals("base", users.get(key)) + users.mutate(key, mutation.ref, "pending") + backend.offline = true + + users.drain(key) + assertEquals(1, users.pending(key).single().attempt) + assertEquals(MutationPendingState.PENDING, users.pending(key).single().state) + + users.drain(key) + assertEquals(2, users.pending(key).single().attempt) + } finally { + users.close() + } + } +} + +private class InspectionRenameMutation { + lateinit var ref: MutatorRef + val registry: MutatorRegistry = + mutatorRegistry { + ref = + mutator( + id = "rename", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { _, value -> MutationPresence.Present(value) } + } +} + +private object InspectionNoopHandle : StoreWriteHandle { + override suspend fun apply( + key: MutationsTestKey, + value: String, + ) = Unit + + override suspend fun markStale(key: MutationsTestKey) = Unit + + override suspend fun confirmFresh( + key: MutationsTestKey, + etag: String?, + ) = Unit +} + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = 25.seconds, testBody = testBody) diff --git a/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournalContractTest.kt b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournalContractTest.kt new file mode 100644 index 000000000..57b9152d5 --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournalContractTest.kt @@ -0,0 +1,2333 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, +) + +package org.mobilenativefoundation.store6.mutations + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.seam.StoreWriteHandle +import org.mobilenativefoundation.store6.core.seam.WallClock +import org.mobilenativefoundation.store6.mutations.storage.InMemoryMutationJournalStorage +import org.mobilenativefoundation.store6.mutations.storage.MutationAckRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationAliasState +import org.mobilenativefoundation.store6.mutations.storage.MutationAttemptRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationClientRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationEffectDisposition +import org.mobilenativefoundation.store6.mutations.storage.MutationEffectKind +import org.mobilenativefoundation.store6.mutations.storage.MutationEffectRecord as StoredEffectRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionPhase as StoredPhase +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationJournalStorage +import org.mobilenativefoundation.store6.mutations.storage.MutationJournalTransaction +import org.mobilenativefoundation.store6.mutations.storage.MutationKeyAliasRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationKeyTombstoneRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationTombstoneState +import org.mobilenativefoundation.store6.testing.TestWallClock +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNotSame +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Restart and durable-journal integration contract for the mutation engine. */ +class MutationJournalContractTest { + @Test + fun journalStorage_isForwardedToEngine() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = restartMutators() + val store = openRestartStore(storage, mutations) + + try { + val mutationId = store.mutate(RestartKey("forwarded", "process-only"), mutations.upsert, "+mine") + storage.transaction { transaction -> + val client = assertNotNull(transaction.client("client-0")) + assertEquals(1L, client.lastAllocatedSequence) + assertEquals(0L, client.retiredThroughSequence) + + val intent = transaction.intents("client-0").single() + assertEquals(mutationId, intent.mutationId) + assertEquals("mutations", intent.namespace) + assertEquals("forwarded", intent.canonicalId) + assertEquals("upsert", intent.mutatorId) + assertEquals(1, intent.mutatorVersion) + assertContentEquals("+mine".encodeToByteArray(), intent.argsBlob) + + val execution = transaction.executions("client-0").single() + assertEquals(1L, execution.clientSequence) + assertEquals(StoredPhase.UNPREPARED, execution.phase) + } + } finally { + store.close() + } + } + + @Test + fun restartHydratesIdentityWithoutPersistingK() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = restartMutators() + val firstKey = RestartKey("identity-only", "must-never-be-durable") + openRestartStore(storage, mutations).use { first -> + first.mutate(firstKey, mutations.upsert, "+mine") + } + + val resolver = RecordingRestartResolver() + val server = RecordingRestartServer(retirementConfirmationCeiling = 0L) + openRestartStore(storage, mutations, server = server, resolver = resolver).use { reopened -> + val pending = reopened.pending(RestartKey("identity-only", "second-process")) + assertEquals(listOf("mutation-1"), pending.map(PendingIntent::mutationId)) + reopened.drain() + } + + assertEquals(listOf("mutations" to "identity-only"), resolver.requests) + val push = server.pushes.single() + assertEquals("identity-only", push.identity.canonicalId) + assertEquals("resolver-process", push.key.processSentinel) + assertNotSame(firstKey, push.key) + storage.transaction { transaction -> + val intent = transaction.intents("client-0").single() + assertEquals("mutations", intent.namespace) + assertEquals("identity-only", intent.canonicalId) + val durableBlobs = + buildList { + add(intent.argsBlob) + transaction.attempts("client-0").forEach { attempt -> + attempt.baseBlob?.let(::add) + attempt.mineBlob?.let(::add) + } + transaction.acks("client-0").forEach { ack -> + ack.authoritativeBlob?.let(::add) + } + } + assertTrue( + durableBlobs.none { blob -> + blob.decodeToString().contains(firstKey.processSentinel) + }, + ) + } + } + + @Test + fun immutableAttemptGeneration_roundTripsAcrossRestart() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = restartMutators() + val server = RecordingRestartServer(cancelPushes = 2) + val key = RestartKey("immutable-generation", "first-process") + + openRestartStore(storage, mutations, server = server).use { first -> + assertEquals("confirmed", first.get(key)) + first.mutate(key, mutations.upsert, "+mine") + assertFailsWith { first.drain(key) } + } + val firstAttempt = storage.transaction { it.attempts("client-0").single() }.snapshot() + assertEquals( + StoredPhase.INFLIGHT, + storage.transaction { it.executions("client-0").single().phase }, + ) + + openRestartStore( + storage, + mutations, + server = server, + resolver = RecordingRestartResolver(), + ).use { reopened -> + assertFailsWith { reopened.drain() } + } + val afterRestart = storage.transaction { it.attempts("client-0").single() }.snapshot() + val restartedExecution = storage.transaction { it.executions("client-0").single() } + + assertEquals(firstAttempt, afterRestart) + assertEquals(StoredPhase.INFLIGHT, restartedExecution.phase) + assertEquals(0, restartedExecution.attempt) + assertEquals(2, server.pushes.size) + assertEquals(server.pushes[0].snapshot(), server.pushes[1].snapshot()) + } + + @Test + fun confirmedRetiredPrefix_roundTripsAcrossRestart() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = restartMutators() + val server = RecordingRestartServer(retirementConfirmationCeiling = 1L) + val firstKey = RestartKey("prefix-one", "first-process") + val secondKey = RestartKey("prefix-two", "first-process") + val thirdKey = RestartKey("prefix-three", "first-process") + + openRestartStore(storage, mutations, server = server).use { first -> + first.mutate(firstKey, mutations.upsert, "+one") + first.drain(firstKey) + storage.transaction { transaction -> + transaction.confirmRetiredThrough("client-0", 1L, 1L) + } + first.mutate(secondKey, mutations.upsert, "+two") + first.mutate(thirdKey, mutations.upsert, "+three") + first.drain(thirdKey) + } + storage.transaction { transaction -> + val client = assertNotNull(transaction.client("client-0")) + assertEquals(1L, client.retiredThroughSequence) + assertEquals(1L, client.serverConfirmedRetiredThroughSequence) + } + + openRestartStore( + storage, + mutations, + server = server, + resolver = RecordingRestartResolver(), + ).use { reopened -> + reopened.drain(secondKey) + val fourthKey = RestartKey("prefix-four", "second-process") + reopened.mutate(fourthKey, mutations.upsert, "+four") + reopened.drain(fourthKey) + } + + storage.transaction { transaction -> + val client = assertNotNull(transaction.client("client-0")) + assertEquals(4L, client.lastAllocatedSequence) + assertEquals(4L, client.retiredThroughSequence) + assertEquals(1L, client.serverConfirmedRetiredThroughSequence) + } + val fourthPush = server.pushes.single { push -> push.clientSequence == 4L } + assertEquals(3L, fourthPush.retiredThroughSequence) + } + + @Test + fun inspectionSnapshots_hydrateIdenticallyAcrossRestart() = runTest { + val storage = InMemoryMutationJournalStorage() + seedEveryExecutionPhase(storage) + val mutations = restartMutators() + + val before = + openRestartStore(storage, mutations).use { first -> + InspectionSnapshot( + pending = first.pendingWrites().map(PendingIntent::snapshot), + keyed = phaseKeyedSnapshots(first), + deadLetters = first.deadLetters().map(DeadLetter::snapshot), + ) + } + val after = + openRestartStore(storage, mutations).use { reopened -> + InspectionSnapshot( + pending = reopened.pendingWrites().map(PendingIntent::snapshot), + keyed = phaseKeyedSnapshots(reopened), + deadLetters = reopened.deadLetters().map(DeadLetter::snapshot), + ) + } + + assertEquals(before, after) + assertEquals( + listOf( + MutationPendingState.PENDING, + MutationPendingState.PENDING, + MutationPendingState.INFLIGHT, + MutationPendingState.REFRESHING, + MutationPendingState.ADOPTING, + MutationPendingState.APPLYING_EFFECTS, + ), + after.pending.map(PendingSnapshot::state), + ) + assertEquals(listOf("mutation-7"), after.deadLetters.map(DeadLetterSnapshot::mutationId)) + assertFalse(after.pending.any { row -> row.mutationId == "mutation-8" }) + } + + @Test + fun normalizedFailure_roundTripsWithoutRawCauseBytes() = runTest { + val storage = InMemoryMutationJournalStorage() + seedParkedFailure(storage) + val mutations = restartMutators() + + val first = + openRestartStore(storage, mutations).use { store -> + store.deadLetters().single().snapshot() + } + val second = + openRestartStore(storage, mutations).use { store -> + store.deadLetters().single().snapshot() + } + + assertEquals(first, second) + assertEquals(MutationFailureKind.CODEC, second.failureKind) + assertEquals("codec-version", second.failureDetail) + assertEquals("unsupported durable codec", second.failureMessage) + assertFalse(second.failureMessage.contains("Throwable")) + assertFalse(second.failureMessage.contains("\n")) + } + + @Test + fun persistenceFailure_preservesLastDurablePhase() = runTest { + val delegate = InMemoryMutationJournalStorage() + val storage = ArmableFailingStorage(delegate) + val mutations = restartMutators() + val key = RestartKey("persistence-failure", "first-process") + + openRestartStore(storage, mutations).use { first -> + first.mutate(key, mutations.upsert, "+mine") + storage.failNextReadyAdvance = true + assertFailsWith { first.drain(key) } + } + delegate.transaction { transaction -> + assertEquals(StoredPhase.UNPREPARED, transaction.executions("client-0").single().phase) + assertTrue(transaction.attempts("client-0").isEmpty()) + } + + val server = RecordingRestartServer() + openRestartStore( + delegate, + mutations, + server = server, + resolver = RecordingRestartResolver(), + ).use { reopened -> + assertEquals(MutationPendingState.PENDING, reopened.pendingWrites().single().state) + reopened.drain() + } + assertEquals(1, server.pushes.size) + } + + @Test + fun globalDrainEnumeratesDurableIdentitiesAfterRestart() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = restartMutators() + openRestartStore(storage, mutations).use { first -> + first.mutate(RestartKey("enumerated-a", "first-process"), mutations.upsert, "+a") + first.mutate(RestartKey("enumerated-b", "first-process"), mutations.upsert, "+b") + } + + val resolver = RecordingRestartResolver() + val server = RecordingRestartServer() + openRestartStore(storage, mutations, server = server, resolver = resolver).use { reopened -> + reopened.drain() + } + + assertEquals( + listOf("mutations" to "enumerated-a", "mutations" to "enumerated-b"), + resolver.requests, + ) + assertEquals( + listOf("enumerated-a", "enumerated-b"), + server.pushes.map { it.key.canonicalId() }, + ) + } + + @Test + fun valueCodecVersionAndBlobs_roundTripAcrossRestart() = runTest { + val backing = InMemoryMutationJournalStorage() + val mutations = restartMutators() + val writingCodec = + StrictRecordingStringCodec( + supportedVersions = setOf(7), + encodeVersion = 7, + ) + val server = + RecordingRestartServer( + cancelPushes = 1, + retirementConfirmationCeiling = 0L, + ) + val key = RestartKey("value-codec", "first-process") + + openRestartStore( + backing, + mutations, + server = server, + valueCodecVersion = 7, + valueCodec = writingCodec, + ).use { first -> + assertEquals("confirmed", first.get(key)) + first.mutate(key, mutations.upsert, "+mine") + assertFailsWith { first.drain(key) } + } + writingCodec.encodedBuffers.forEach { bytes -> bytes.fill(0) } + val beforeRestart = backing.transaction { it.attempts("client-0").single().snapshot() } + assertEquals(7, beforeRestart.codecVersion) + assertEquals("store6-codec-7:confirmed".encodeToByteArray().toList(), beforeRestart.baseBlob) + assertEquals("store6-codec-7:confirmed+mine".encodeToByteArray().toList(), beforeRestart.mineBlob) + + val failPointStorage = FailPointJournalStorage(backing) + failPointStorage.armKillAfterCommit(JournalFailPointBoundary.ACK_RECEIPT) + val replayCodec = + StrictRecordingStringCodec( + supportedVersions = setOf(7, 9), + encodeVersion = 9, + ) + openRestartStore( + failPointStorage, + mutations, + server = server, + resolver = RecordingRestartResolver(), + valueCodecVersion = 9, + valueCodec = replayCodec, + ).use { reopened -> + assertEquals(listOf("mutation-1"), reopened.pendingWrites().map(PendingIntent::mutationId)) + assertEquals(listOf(7, 7), replayCodec.decodeVersions) + val processDeath = + assertFailsWith { reopened.drain() } + assertTrue(processDeath.committed) + } + + val afterAckCommit = + backing.transaction { transaction -> + Triple( + transaction.attempts("client-0").single().snapshot(), + transaction.acks("client-0").single(), + transaction.executions("client-0").single(), + ) + } + assertEquals(beforeRestart, afterAckCommit.first) + assertEquals(7, afterAckCommit.first.codecVersion) + assertEquals(9, afterAckCommit.second.valueCodecVersion) + assertContentEquals( + "store6-codec-9:confirmed+mine".encodeToByteArray(), + afterAckCommit.second.authoritativeBlob, + ) + assertEquals(StoredPhase.ACKED, afterAckCommit.third.phase) + assertEquals(2, server.pushes.size) + val cancelledPush = server.pushes.first() + val replayedPush = server.pushes.last() + assertEquals(7, cancelledPush.valueCodecVersion) + assertEquals(7, replayedPush.valueCodecVersion) + assertEquals(1, replayedPush.generation) + assertEquals(cancelledPush.generation, replayedPush.generation) + assertEquals(cancelledPush.idempotencyKey, replayedPush.idempotencyKey) + assertEquals(cancelledPush.identity.namespace, replayedPush.identity.namespace) + assertEquals(cancelledPush.identity.canonicalId, replayedPush.identity.canonicalId) + assertEquals( + assertIs>(cancelledPush.base).value, + assertIs>(replayedPush.base).value, + ) + assertEquals( + assertIs>(cancelledPush.mine).value, + assertIs>(replayedPush.mine).value, + ) + assertEquals( + cancelledPush.baseMeta?.writtenAtEpochMillis, + replayedPush.baseMeta?.writtenAtEpochMillis, + ) + assertEquals(cancelledPush.baseMeta?.etag, replayedPush.baseMeta?.etag) + assertEquals("confirmed", assertIs>(replayedPush.base).value) + assertEquals("confirmed+mine", assertIs>(replayedPush.mine).value) + + replayCodec.encodedBuffers.forEach { bytes -> bytes.fill(0) } + replayCodec.decodeInputs.forEach { bytes -> bytes.fill(0) } + val afterCodecBufferMutation = + backing.transaction { transaction -> + Triple( + transaction.attempts("client-0").single().snapshot(), + transaction.acks("client-0").single(), + transaction.executions("client-0").single(), + ) + } + assertEquals(beforeRestart, afterCodecBufferMutation.first) + assertEquals(9, afterCodecBufferMutation.second.valueCodecVersion) + assertContentEquals( + "store6-codec-9:confirmed+mine".encodeToByteArray(), + afterCodecBufferMutation.second.authoritativeBlob, + ) + assertEquals(StoredPhase.ACKED, afterCodecBufferMutation.third.phase) + + val adoptingCodec = + StrictRecordingStringCodec( + supportedVersions = setOf(7, 9), + encodeVersion = 9, + ) + val pushesBeforeAdoption = server.pushes.size + openRestartStore( + backing, + mutations, + server = server, + resolver = RecordingRestartResolver(), + valueCodecVersion = 9, + valueCodec = adoptingCodec, + ).use { reopened -> + reopened.drain() + assertTrue(reopened.pendingWrites().isEmpty()) + } + + assertEquals(pushesBeforeAdoption, server.pushes.size) + assertTrue(9 in adoptingCodec.decodeVersions) + } + + @Test + fun unknownPreAckValueCodecVersion_hydratesAsParkableCodecFailure() = runTest { + val storage = InMemoryMutationJournalStorage() + seedCodecExecution(storage, StoredPhase.READY, valueCodecVersion = 99) + val server = RecordingRestartServer() + val codec = StrictRecordingStringCodec(supportedVersions = setOf(1)) + + openRestartStore( + storage, + restartMutators(), + server = server, + valueCodec = codec, + ).use { reopened -> + assertEquals(MutationPendingState.PENDING, reopened.pendingWrites().single().state) + reopened.drain() + assertTrue(reopened.pendingWrites().isEmpty()) + val deadLetter = reopened.deadLetters().single() + assertEquals("mutation-1", deadLetter.mutationId) + assertEquals(1, deadLetter.generation) + assertEquals(0, deadLetter.attempts) + assertEquals(MutationFailureKind.CODEC, deadLetter.failure.kind) + assertEquals("value-codec-pre-ack", deadLetter.failure.detail) + } + + val failure = storage.transaction { it.failures("client-0").single() } + assertEquals(MutationFailureKind.CODEC, failure.kind) + assertEquals(1, failure.generation) + assertEquals("value-codec-pre-ack", failure.detail) + assertFalse(failure.message.contains("IllegalArgumentException")) + assertFalse(failure.message.contains('\n')) + val execution = storage.transaction { it.executions("client-0").single() } + assertEquals(StoredPhase.PARKED, execution.phase) + assertEquals(failure.failureId, execution.activeFailureId) + assertTrue(server.pushes.isEmpty()) + } + + @Test + fun unknownAckValueCodecVersion_hydratesAsRetryableAckedCodecFailure() = runTest { + val storage = InMemoryMutationJournalStorage() + seedCodecExecution(storage, StoredPhase.ACKED, valueCodecVersion = 99) + val server = RecordingRestartServer() + val codec = StrictRecordingStringCodec(supportedVersions = setOf(1)) + + openRestartStore( + storage, + restartMutators(), + server = server, + valueCodec = codec, + ).use { reopened -> + assertEquals(MutationPendingState.ADOPTING, reopened.pendingWrites().single().state) + reopened.drain() + assertTrue(reopened.deadLetters().isEmpty()) + } + + val failures = storage.transaction { it.failures("client-0") } + val failure = failures.single() + assertEquals(MutationFailureKind.CODEC, failure.kind) + assertEquals(1, failure.generation) + assertEquals("value-codec-acked", failure.detail) + assertFalse(failure.message.contains("IllegalArgumentException")) + assertFalse(failure.message.contains('\n')) + val execution = storage.transaction { it.executions("client-0").single() } + assertEquals(StoredPhase.ACKED, execution.phase) + assertNull(execution.activeFailureId) + assertTrue(server.pushes.isEmpty()) + } + + @Test + fun argsCodecVersionAndBlob_roundTripAcrossRestart() = runTest { + val storage = InMemoryMutationJournalStorage() + val writingCodec = StrictRecordingStringCodec(supportedVersions = setOf(3)) + val firstMutations = restartMutators(argsVersion = 3, argsCodec = writingCodec) + val key = RestartKey("args-codec", "first-process") + + openRestartStore(storage, firstMutations).use { first -> + first.mutate(key, firstMutations.upsert, "+args") + } + writingCodec.encodedBuffers.forEach { bytes -> bytes.fill(0) } + val beforeRestart = storage.transaction { it.intents("client-0").single() } + assertEquals(3, beforeRestart.mutatorVersion) + assertContentEquals("+args".encodeToByteArray(), beforeRestart.argsBlob) + + val readingCodec = StrictRecordingStringCodec(supportedVersions = setOf(3)) + val reopenedMutations = restartMutators(argsVersion = 4, argsCodec = readingCodec) + val server = RecordingRestartServer(retirementConfirmationCeiling = 0L) + openRestartStore(storage, reopenedMutations, server = server).use { reopened -> + assertEquals(listOf("mutation-1"), reopened.pendingWrites().map(PendingIntent::mutationId)) + reopened.drain(RestartKey("args-codec", "second-process")) + } + + assertEquals(listOf(3), readingCodec.decodeVersions) + assertEquals("+args", (server.pushes.single().mine as MutationPresence.Present).value) + readingCodec.decodeInputs.single().fill(0) + assertContentEquals( + "+args".encodeToByteArray(), + storage.transaction { it.intents("client-0").single().argsBlob }, + ) + } + + @Test + fun callerMutatedArgs_doNotChangeAcceptedProjectionOrRestartReplay() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = mutableArgsMutators() + val key = RestartKey("mutable-args", "first-process") + val args = MutableSuffix("+accepted") + + val first = openMutableArgsEngine(storage, mutations) + first.ensureHydrated() + first.mutate(key, mutations.upsert, args) + assertEquals("confirmed+accepted", first.overlay.apply(key, "confirmed")) + + args.value = "+caller-change" + assertEquals("confirmed+accepted", first.overlay.apply(key, "confirmed")) + + val reopened = openMutableArgsEngine(storage, mutations) + reopened.ensureHydrated() + assertEquals( + "confirmed+accepted", + reopened.overlay.apply(RestartKey("mutable-args", "second-process"), "confirmed"), + ) + } + + @Test + fun unknownMutatorOrArgsVersion_hydratesAsNormalizedCodecFailure() = runTest { + val storage = InMemoryMutationJournalStorage() + storage.transaction { transaction -> + seedClient(transaction, lastAllocatedSequence = 2L) + seedUnpreparedIntent( + transaction, + sequence = 1L, + mutationId = "mutation-1", + canonicalId = "missing-mutator", + mutatorId = "not-registered", + mutatorVersion = 1, + argsBlob = "+one".encodeToByteArray(), + ) + seedUnpreparedIntent( + transaction, + sequence = 2L, + mutationId = "mutation-2", + canonicalId = "unknown-args-version", + mutatorId = "upsert", + mutatorVersion = 99, + argsBlob = "+two".encodeToByteArray(), + ) + } + val server = RecordingRestartServer() + val strictArgs = StrictRecordingStringCodec(supportedVersions = setOf(1)) + + openRestartStore( + storage, + restartMutators(argsCodec = strictArgs), + server = server, + ).use { reopened -> + assertEquals( + listOf("mutation-1", "mutation-2"), + reopened.pendingWrites().map(PendingIntent::mutationId), + ) + reopened.drain() + assertTrue(reopened.pendingWrites().isEmpty()) + val deadLetters = reopened.deadLetters() + assertEquals(2, deadLetters.size) + assertEquals( + mapOf( + "mutation-1" to "mutator-missing", + "mutation-2" to "args-codec", + ), + deadLetters.associate { deadLetter -> + deadLetter.mutationId to deadLetter.failure.detail + }, + ) + assertTrue( + deadLetters.all { deadLetter -> + deadLetter.generation == 0 && + deadLetter.attempts == 0 && + deadLetter.failure.kind == MutationFailureKind.CODEC + }, + ) + } + + val failures = storage.transaction { it.failures("client-0") } + assertEquals(2, failures.size) + assertTrue(failures.all { failure -> failure.kind == MutationFailureKind.CODEC }) + assertEquals(setOf("mutator-missing", "args-codec"), failures.map { it.detail }.toSet()) + assertTrue(failures.none { failure -> failure.message.contains("Throwable") }) + assertTrue(failures.none { failure -> failure.message.contains('\n') }) + val failuresBySequence = failures.associateBy { failure -> failure.clientSequence } + val executions = storage.transaction { it.executions("client-0") } + assertTrue( + executions.all { execution -> + execution.phase == StoredPhase.PARKED && + execution.currentGeneration == 0 && + execution.activeFailureId == + failuresBySequence.getValue(execution.clientSequence).failureId + }, + ) + assertEquals(listOf(0, 0), failures.map { it.generation }) + assertTrue(server.pushes.isEmpty()) + + val acknowledgedStorage = InMemoryMutationJournalStorage() + seedCodecExecution( + acknowledgedStorage, + StoredPhase.ACKED, + valueCodecVersion = 1, + mutatorId = "not-registered-after-ack", + ) + val acknowledgedServer = RecordingRestartServer(retirementConfirmationCeiling = 0L) + openRestartStore( + acknowledgedStorage, + restartMutators(), + server = acknowledgedServer, + ).use { reopened -> + assertEquals(MutationPendingState.ADOPTING, reopened.pendingWrites().single().state) + reopened.drain() + } + assertTrue(acknowledgedServer.pushes.isEmpty()) + assertTrue(acknowledgedStorage.transaction { it.failures("client-0") }.isEmpty()) + assertEquals( + StoredPhase.RETIRED, + acknowledgedStorage.transaction { it.executions("client-0").single().phase }, + ) + } + + @Test + fun deleteArgs_rejectsUnknownVersionOrNonEmptyBlobAsCodec() = runTest { + val storage = InMemoryMutationJournalStorage() + storage.transaction { transaction -> + seedClient(transaction, lastAllocatedSequence = 3L) + seedUnpreparedIntent( + transaction, + sequence = 1L, + mutationId = "mutation-1", + canonicalId = "delete-version", + mutatorId = "delete", + mutatorVersion = 2, + argsBlob = ByteArray(0), + ) + seedUnpreparedIntent( + transaction, + sequence = 2L, + mutationId = "mutation-2", + canonicalId = "delete-bytes", + mutatorId = "delete", + mutatorVersion = 1, + argsBlob = byteArrayOf(1), + ) + seedUnpreparedIntent( + transaction, + sequence = 3L, + mutationId = "mutation-3", + canonicalId = "delete-valid-control", + mutatorId = "delete", + mutatorVersion = 1, + argsBlob = ByteArray(0), + ) + } + val server = RecordingRestartServer() + + openRestartStore(storage, restartMutators(), server = server).use { reopened -> + assertEquals(3, reopened.pendingWrites().size) + reopened.drain() + assertTrue(reopened.pendingWrites().isEmpty()) + val deadLetters = reopened.deadLetters() + assertEquals(2, deadLetters.size) + assertEquals( + mapOf( + "mutation-1" to "args-codec", + "mutation-2" to "args-codec", + ), + deadLetters.associate { deadLetter -> + deadLetter.mutationId to deadLetter.failure.detail + }, + ) + assertTrue( + deadLetters.all { deadLetter -> + deadLetter.generation == 0 && + deadLetter.attempts == 0 && + deadLetter.failure.kind == MutationFailureKind.CODEC + }, + ) + } + + val failures = storage.transaction { it.failures("client-0") } + assertEquals(2, failures.size) + assertTrue(failures.all { failure -> failure.kind == MutationFailureKind.CODEC }) + assertTrue(failures.all { failure -> failure.detail == "args-codec" }) + assertTrue(failures.all { failure -> failure.generation == 0 }) + assertTrue(failures.any { failure -> failure.message.contains("fixed at version 1") }) + assertTrue(failures.any { failure -> failure.message.contains("zero bytes") }) + val push = server.pushes.single() + assertEquals(3L, push.clientSequence) + assertEquals(MutationPresence.Absent, push.mine) + val failuresBySequence = failures.associateBy { failure -> failure.clientSequence } + val executions = storage.transaction { it.executions("client-0") } + assertTrue( + executions.take(2).all { execution -> + execution.phase == StoredPhase.PARKED && + execution.currentGeneration == 0 && + execution.activeFailureId == + failuresBySequence.getValue(execution.clientSequence).failureId + }, + ) + assertEquals(StoredPhase.RETIRED, executions.last().phase) + } + + @Test + fun effectTargetsAndDispositions_roundTripAcrossRestart() = runTest { + val storage = InMemoryMutationJournalStorage() + val clock = TestWallClock(startEpochMillis = 1_000L) + val mutations = + restartMutators( + stales = { _, _ -> + StaleSet( + keys = + setOf( + RestartKey("z", "effect"), + RestartKey("a", "effect-one"), + RestartKey("a", "effect-duplicate"), + ), + namespaces = + setOf( + StoreNamespace("zeta"), + StoreNamespace("alpha"), + StoreNamespace("alpha"), + ), + ) + }, + ) + var effectsAtPush: List = emptyList() + val server = + RecordingRestartServer( + beforePush = { + effectsAtPush = storage.transaction { it.effects("client-0").map(StoredEffectRecord::snapshot) } + }, + ) + val effectTargetStop = IllegalStateException("effect target unavailable") + val effectResolver = + MutationKeyResolver { identity -> + if (identity.canonicalId == "a" || identity.canonicalId == "z") { + throw effectTargetStop + } + RestartKey(identity.canonicalId, "resolver-process") + } + fun openEffectStore(): MutationStore = + mutationStore( + registry = mutations.registry, + server = server, + keyResolver = effectResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + ) { + fetcher { "confirmed" } + journalStorage(storage) + wallClock(clock) + bookkeeper( + object : + org.mobilenativefoundation.store6.core.seam.Bookkeeper by + MutationBookkeeper() { + override suspend fun advanceStaleWatermark(namespace: StoreNamespace) { + throw effectTargetStop + } + }, + ) + } + suspend fun drainAllowingEffectTargetStop(drain: suspend () -> Unit) { + try { + drain() + } catch (cancellation: CancellationException) { + throw cancellation + } catch (failure: Throwable) { + val configuredStoreFailure = + failure is org.mobilenativefoundation.store6.core.StoreException && + failure.cause === effectTargetStop + if (failure !== effectTargetStop && !configuredStoreFailure) { + throw failure + } + } + } + + openEffectStore().use { first -> + first.mutate(RestartKey("effect-source", "first-process"), mutations.upsert, "+mine") + drainAllowingEffectTargetStop { + first.drain(RestartKey("effect-source", "first-process")) + } + assertEquals( + MutationPendingState.APPLYING_EFFECTS, + first.pendingWrites().single().state, + ) + } + + val expected = + listOf( + EffectSnapshot(0, MutationEffectKind.NAMESPACE, "alpha", null, 1_000L), + EffectSnapshot(1, MutationEffectKind.NAMESPACE, "zeta", null, 1_000L), + EffectSnapshot(2, MutationEffectKind.KEY, "mutations", "a", 1_000L), + EffectSnapshot(3, MutationEffectKind.KEY, "mutations", "z", 1_000L), + ) + assertEquals(expected, effectsAtPush) + assertEquals(expected, storage.transaction { it.effects("client-0").map(StoredEffectRecord::snapshot) }) + assertEquals( + StoredPhase.EFFECTS_PENDING, + storage.transaction { it.executions("client-0").single().phase }, + ) + + openEffectStore().use { reopened -> + assertEquals( + MutationPendingState.APPLYING_EFFECTS, + reopened.pendingWrites().single().state, + ) + assertEquals( + expected, + reopened.durableEffectsForInspection("mutation-1").map(StoredEffectRecord::snapshot), + ) + drainAllowingEffectTargetStop { reopened.drain() } + } + assertEquals(1, server.pushes.size) + assertEquals(expected, storage.transaction { it.effects("client-0").map(StoredEffectRecord::snapshot) }) + } + + @Test + fun aliasEdgesAndActivation_roundTripAcrossRestart() = runTest { + val delegate = InMemoryMutationJournalStorage() + delegate.transaction { transaction -> + seedClient(transaction, lastAllocatedSequence = 0L) + insertActiveAlias( + transaction, + source = "canonical", + target = "terminal", + creatorClientId = "remote-client", + creatorSequence = 1L, + ) + } + val storage = ArmablePhaseFailingStorage(delegate) + val mutations = restartMutators() + val source = RestartKey("source", "first-process") + val canonical = RestartKey("canonical", "server-process") + val server = + RecordingRestartServer( + canonicalTarget = canonical, + retirementConfirmationCeiling = 0L, + ) + + openRestartStore(storage, mutations, server = server).use { first -> + first.mutate(source, mutations.upsert, "+mine") + storage.failNextPhase = StoredPhase.RETIRED + assertFailsWith { first.drain(source) } + } + val afterFailure = delegate.transaction { it.aliases().map(MutationKeyAliasRecord::snapshot) } + assertEquals(MutationAliasState.PENDING, afterFailure.single { it.sourceId == "source" }.state) + assertEquals(MutationAliasState.ACTIVE, afterFailure.single { it.sourceId == "canonical" }.state) + // Adoption already committed its ACKED -> EFFECTS_PENDING boundary; the injected + // RETIRED write failed inside the separate finalization transaction. + assertEquals( + StoredPhase.EFFECTS_PENDING, + delegate.transaction { it.executions("client-0").single().phase }, + ) + assertEquals(0L, assertNotNull(delegate.transaction { it.client("client-0") }).retiredThroughSequence) + + val resumeResolver = RecordingRestartResolver() + openRestartStore( + storage, + mutations, + server = server, + resolver = resumeResolver, + ).use { reopened -> + reopened.drain() + } + assertEquals(1, server.pushes.size) + val afterResume = delegate.transaction { it.aliases().map(MutationKeyAliasRecord::snapshot) } + assertEquals(MutationAliasState.ACTIVE, afterResume.single { it.sourceId == "source" }.state) + assertEquals(StoredPhase.RETIRED, delegate.transaction { it.executions("client-0").single().phase }) + assertEquals(1L, assertNotNull(delegate.transaction { it.client("client-0") }).retiredThroughSequence) + + val terminalResolver = RecordingRestartResolver() + openRestartStore( + delegate, + mutations, + resolver = terminalResolver, + ).use { reopened -> + assertEquals("confirmed", reopened.get(RestartKey("source", "third-process"))) + } + assertEquals(listOf("mutations" to "terminal"), terminalResolver.requests) + } + + @Test + fun ackedChainedAliasResume_adoptsAtTerminalIdentityAfterRestart() = runTest { + val storage = InMemoryMutationJournalStorage() + storage.transaction { transaction -> + seedClient(transaction, lastAllocatedSequence = 1L) + seedUnpreparedIntent( + transaction = transaction, + sequence = 1L, + mutationId = "mutation-1", + canonicalId = "source", + mutatorId = "upsert", + mutatorVersion = 1, + argsBlob = "+mine".encodeToByteArray(), + ) + transaction.insertAttempt( + MutationAttemptRecord( + clientId = "client-0", + clientSequence = 1L, + generation = 1, + effectiveNamespace = "mutations", + effectiveCanonicalId = "source", + valueCodecVersion = 1, + basePresence = MutationPresenceState.PRESENT, + baseBlob = "confirmed".encodeToByteArray(), + minePresence = MutationPresenceState.PRESENT, + mineBlob = "confirmed+mine".encodeToByteArray(), + preconditionMetaPresent = false, + preconditionWrittenAt = null, + preconditionEtag = null, + advertisedRetiredThroughSequence = 0L, + generationIdempotencyKey = "client-0:1:g1", + preparedAt = 200L, + conflictMetaPresent = null, + conflictWrittenAt = null, + conflictEtag = null, + conflictReceivedAt = null, + ), + ) + val ready = + MutationExecutionRecord( + clientId = "client-0", + clientSequence = 1L, + phase = StoredPhase.READY, + currentGeneration = 1, + attempt = 0, + lastAttemptAt = null, + activeFailureId = null, + retiredAt = null, + ) + transaction.advanceExecution(ready) + transaction.advanceExecution(ready.copyPhase(StoredPhase.INFLIGHT)) + transaction.insertAck( + MutationAckRecord( + clientId = "client-0", + clientSequence = 1L, + generation = 1, + authoritativePresence = MutationPresenceState.PRESENT, + authoritativeBlob = "authoritative-chain".encodeToByteArray(), + valueCodecVersion = 1, + etag = "etag-chain", + canonicalTargetNamespace = "mutations", + canonicalTargetId = "canonical", + receivedAt = 300L, + ), + ) + transaction.insertAlias( + MutationKeyAliasRecord( + sourceNamespace = "mutations", + sourceCanonicalId = "source", + targetNamespace = "mutations", + targetCanonicalId = "canonical", + state = MutationAliasState.PENDING, + createdByClientId = "client-0", + createdBySequence = 1L, + createdAt = 300L, + activatedAt = null, + ), + ) + transaction.advanceExecution( + MutationExecutionRecord( + clientId = "client-0", + clientSequence = 1L, + phase = StoredPhase.ACKED, + currentGeneration = 1, + attempt = 1, + lastAttemptAt = 300L, + activeFailureId = null, + retiredAt = null, + ), + ) + insertActiveAlias( + transaction = transaction, + source = "canonical", + target = "terminal", + creatorClientId = "remote-client", + creatorSequence = 1L, + ) + } + + val mutations = restartMutators() + val server = RecordingRestartServer() + val resolver = RecordingRestartResolver() + val handle = RecordingRestartWriteHandle() + val engine = + MutationEngine( + registry = mutations.registry, + server = server, + journal = + StorageBackedMutationJournal( + storage = storage, + registrations = mutations.registry.registrations, + clientId = "client-0", + hydrateOnFirstUse = true, + ), + keyResolver = resolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + clientId = "client-0", + ) + engine.bind(handle) + + engine.drain(RestartKey("source", "restart")) + + assertTrue(server.pushes.isEmpty()) + assertEquals(listOf("terminal" to "authoritative-chain"), handle.applied) + assertEquals( + listOf>("terminal" to "etag-chain"), + handle.confirmed, + ) + assertEquals(listOf("mutations" to "terminal"), resolver.requests) + } + + @Test + fun activeAliasSourceSibling_rehomesDuringRestartHydration() = runTest { + val storage = InMemoryMutationJournalStorage() + storage.transaction { transaction -> + seedClient(transaction, lastAllocatedSequence = 1L) + seedUnpreparedIntent( + transaction = transaction, + sequence = 1L, + mutationId = "mutation-1", + canonicalId = "source", + mutatorId = "upsert", + mutatorVersion = 1, + argsBlob = "+sibling".encodeToByteArray(), + ) + insertActiveAlias( + transaction = transaction, + source = "source", + target = "target", + creatorClientId = "remote-client", + creatorSequence = 1L, + ) + } + val mutations = restartMutators() + + openRestartStore(storage, mutations).use { reopened -> + val pending = reopened.pending(RestartKey("source", "restart")) + assertEquals(listOf("mutation-1"), pending.map(PendingIntent::mutationId)) + assertEquals("target", pending.single().canonicalId) + assertEquals( + listOf("target"), + reopened.pendingWrites().map(PendingIntent::canonicalId), + ) + } + } + + @Test + fun deletePresentDeletePersistsDistinctTombstoneGenerations() = runTest { + val storage = InMemoryMutationJournalStorage() + storage.transaction { transaction -> + seedClient(transaction, lastAllocatedSequence = 3L) + seedAcknowledgedIntent(transaction, "client-0", 1L, MutationPresenceState.ABSENT, retire = true) + seedAcknowledgedIntent(transaction, "client-0", 2L, MutationPresenceState.PRESENT, retire = true) + seedAcknowledgedIntent(transaction, "client-0", 3L, MutationPresenceState.ABSENT) + insertActiveTombstone(transaction, "entity", "client-0", 1L, 10L, 11L) + supersedeTombstone(transaction, "entity", "client-0", 1L, 10L, 11L, "client-0", 2L, 20L) + insertActiveTombstone(transaction, "entity", "client-0", 3L, 30L, 31L) + } + + val rows = hydrateAndReadTombstones(storage, "entity") + assertEquals(2, rows.size) + assertEquals( + TombstoneSnapshot( + 1L, + MutationTombstoneState.SUPERSEDED, + 10L, + 11L, + "client-0", + 2L, + 20L, + ), + rows[0], + ) + assertEquals( + TombstoneSnapshot(3L, MutationTombstoneState.ACTIVE, 30L, 31L, null, null, null), + rows[1], + ) + } + + @Test + fun tombstonesAllowAtMostOnePendingAndOneActiveGeneration() = runTest { + val storage = InMemoryMutationJournalStorage() + storage.transaction { transaction -> + seedClient(transaction, lastAllocatedSequence = 3L) + seedAcknowledgedIntent(transaction, "client-0", 1L, MutationPresenceState.ABSENT, retire = true) + seedAcknowledgedIntent(transaction, "client-0", 2L, MutationPresenceState.ABSENT, retire = true) + seedAcknowledgedIntent(transaction, "client-0", 3L, MutationPresenceState.ABSENT) + insertActiveTombstone(transaction, "entity", "client-0", 1L, 10L, 11L) + transaction.insertTombstone( + tombstone("entity", "client-0", 2L, MutationTombstoneState.PENDING, 20L), + ) + } + assertFailsWith { + storage.transaction { transaction -> + transaction.insertTombstone( + tombstone("entity", "client-0", 3L, MutationTombstoneState.PENDING, 30L), + ) + } + } + assertFailsWith { + storage.transaction { transaction -> + transaction.advanceTombstone( + tombstone( + "entity", + "client-0", + 2L, + MutationTombstoneState.ACTIVE, + 20L, + activatedAt = 21L, + ), + ) + } + } + + val rows = hydrateAndReadTombstones(storage, "entity") + assertEquals(listOf(MutationTombstoneState.ACTIVE, MutationTombstoneState.PENDING), rows.map { it.state }) + assertEquals(listOf(1L, 2L), rows.map { it.creatorSequence }) + } + + @Test + fun newAbsentActivationSupersedesPriorActiveGeneration() = runTest { + val storage = InMemoryMutationJournalStorage() + storage.transaction { transaction -> + seedClient(transaction, lastAllocatedSequence = 2L) + seedAcknowledgedIntent(transaction, "client-0", 1L, MutationPresenceState.ABSENT, retire = true) + seedAcknowledgedIntent(transaction, "client-0", 2L, MutationPresenceState.ABSENT) + insertActiveTombstone(transaction, "entity", "client-0", 1L, 10L, 11L) + transaction.insertTombstone( + tombstone("entity", "client-0", 2L, MutationTombstoneState.PENDING, 20L), + ) + supersedeTombstone(transaction, "entity", "client-0", 1L, 10L, 11L, "client-0", 2L, 21L) + transaction.advanceTombstone( + tombstone( + "entity", + "client-0", + 2L, + MutationTombstoneState.ACTIVE, + 20L, + activatedAt = 21L, + ), + ) + } + + val rows = hydrateAndReadTombstones(storage, "entity") + assertEquals(MutationTombstoneState.SUPERSEDED, rows[0].state) + assertEquals(2L, rows[0].supersededBySequence) + assertEquals(MutationTombstoneState.ACTIVE, rows[1].state) + assertEquals(21L, rows[1].activatedAt) + } + + @Test + fun laterPresentSupersedesActiveTombstone() = runTest { + val storage = InMemoryMutationJournalStorage() + storage.transaction { transaction -> + seedClient(transaction, lastAllocatedSequence = 2L) + seedAcknowledgedIntent(transaction, "client-0", 1L, MutationPresenceState.ABSENT, retire = true) + seedAcknowledgedIntent(transaction, "client-0", 2L, MutationPresenceState.PRESENT) + insertActiveTombstone(transaction, "entity", "client-0", 1L, 10L, 11L) + supersedeTombstone(transaction, "entity", "client-0", 1L, 10L, 11L, "client-0", 2L, 20L) + } + + val rows = hydrateAndReadTombstones(storage, "entity") + val row = rows.single() + assertEquals(MutationTombstoneState.SUPERSEDED, row.state) + assertEquals(2L, row.supersededBySequence) + assertTrue(rows.none { it.state == MutationTombstoneState.ACTIVE }) + assertTrue(rows.none { it.state == MutationTombstoneState.PENDING }) + } + + @Test + fun supersededByMayReferenceAnyLaterAuthoritativeIntent() = runTest { + val storage = InMemoryMutationJournalStorage() + storage.transaction { transaction -> + seedNamedClient(transaction, "client-0", 0L) + seedNamedClient(transaction, "client-a", 5L) + seedNamedClient(transaction, "client-b", 1L) + seedAcknowledgedIntent(transaction, "client-a", 5L, MutationPresenceState.ABSENT) + seedAcknowledgedIntent(transaction, "client-b", 1L, MutationPresenceState.PRESENT) + insertActiveTombstone(transaction, "entity", "client-a", 5L, 100L, 110L) + supersedeTombstone(transaction, "entity", "client-a", 5L, 100L, 110L, "client-b", 1L, 200L) + } + + val row = hydrateAndReadTombstones(storage, "entity").single() + assertEquals(MutationTombstoneState.SUPERSEDED, row.state) + assertEquals("client-b", row.supersededByClientId) + assertEquals(1L, row.supersededBySequence) + assertTrue(checkNotNull(row.supersededBySequence) < row.creatorSequence) + + val sameClientLowerSuccessor = + tombstone( + canonicalId = "structural-only", + creatorClientId = "client-a", + creatorSequence = 5L, + state = MutationTombstoneState.SUPERSEDED, + createdAt = 100L, + activatedAt = 110L, + supersededByClientId = "client-a", + supersededBySequence = 1L, + supersededAt = 200L, + ) + assertEquals("client-a", sameClientLowerSuccessor.supersededByClientId) + assertEquals(1L, sameClientLowerSuccessor.supersededBySequence) + assertEquals(110L, sameClientLowerSuccessor.activatedAt) + assertTrue( + checkNotNull(sameClientLowerSuccessor.supersededBySequence) < + sameClientLowerSuccessor.createdBySequence, + ) + } +} + +private class RestartKey( + private val id: String, + val processSentinel: String, + override val namespace: StoreNamespace = StoreNamespace("mutations"), +) : StoreKey { + override fun canonicalId(): String = id +} + +private class RestartMutators( + val registry: MutatorRegistry, + val upsert: MutatorRef, + val delete: MutatorRef, +) + +private class MutableSuffix( + var value: String, +) + +private class MutableArgsMutators( + val registry: MutatorRegistry, + val upsert: MutatorRef, +) + +private object MutableSuffixCodec : MutationCodec { + override fun encode(value: MutableSuffix): ByteArray = value.value.encodeToByteArray() + + override fun decode( + version: Int, + bytes: ByteArray, + ): MutableSuffix = MutableSuffix(bytes.decodeToString()) +} + +private fun mutableArgsMutators(): MutableArgsMutators { + lateinit var upsert: MutatorRef + val registry = + mutatorRegistry { + upsert = + upsert( + id = "mutable-upsert", + version = 1, + codec = MutableSuffixCodec, + stales = noStales(), + ) { base, suffix -> + val current = (base as? MutationPresence.Present)?.value.orEmpty() + MutationPresence.Present(current + suffix.value) + } + } + return MutableArgsMutators(registry, upsert) +} + +private fun openMutableArgsEngine( + storage: MutationJournalStorage, + mutations: MutableArgsMutators, +): MutationEngine = + MutationEngine( + registry = mutations.registry, + server = RecordingRestartServer(), + journal = + StorageBackedMutationJournal( + storage = storage, + registrations = mutations.registry.registrations, + clientId = "client-0", + hydrateOnFirstUse = true, + ), + keyResolver = RecordingRestartResolver(), + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + clientId = "client-0", + ) + +private fun restartMutators( + argsVersion: Int = 1, + argsCodec: MutationCodec = FixtureStringArgsCodec, + stales: (RestartKey, String) -> StaleSet = noStales(), +): RestartMutators { + lateinit var upsert: MutatorRef + lateinit var delete: MutatorRef + val registry = + mutatorRegistry { + upsert = + upsert( + id = "upsert", + version = argsVersion, + codec = argsCodec, + stales = stales, + ) { base, suffix -> + val current = (base as? MutationPresence.Present)?.value.orEmpty() + MutationPresence.Present(current + suffix) + } + delete = delete(id = "delete", stales = noStales()) + } + return RestartMutators(registry, upsert, delete) +} + +private fun openRestartStore( + storage: MutationJournalStorage, + mutations: RestartMutators, + server: MutationServer = RecordingRestartServer(), + resolver: MutationKeyResolver = RecordingRestartResolver(), + valueCodecVersion: Int = 1, + valueCodec: MutationCodec = FixtureStringArgsCodec, + clock: WallClock? = null, +): MutationStore = + mutationStore( + registry = mutations.registry, + server = server, + keyResolver = resolver, + valueCodecVersion = valueCodecVersion, + valueCodec = valueCodec, + ) { + fetcher { "confirmed" } + journalStorage(storage) + clock?.let { selected -> wallClock(selected) } + } + +private inline fun MutationStore.use( + block: (MutationStore) -> R, +): R = + try { + block(this) + } finally { + close() + } + +private class RecordingRestartResolver : MutationKeyResolver { + val requests = mutableListOf>() + + override suspend fun resolve(identity: MutationKeyIdentity): RestartKey { + requests += identity.namespace to identity.canonicalId + return RestartKey( + identity.canonicalId, + "resolver-process", + StoreNamespace(identity.namespace), + ) + } +} + +private class RecordingRestartServer( + private var cancelPushes: Int = 0, + private val canonicalTarget: RestartKey? = null, + private val beforePush: suspend (MutationPush) -> Unit = {}, + private val retirementConfirmationCeiling: Long? = null, +) : MutationServer { + val pushes = mutableListOf>() + + override suspend fun push(request: MutationPush): MutationAck { + pushes += request + beforePush(request) + if (cancelPushes > 0) { + cancelPushes -= 1 + throw CancellationException("transport outcome unknown") + } + return when (val mine = request.mine) { + is MutationPresence.Present -> + MutationPresentAck( + authoritative = mine.value, + etag = "etag-${request.clientSequence}", + canonicalKey = canonicalTarget, + ) + MutationPresence.Absent -> MutationAbsentAck(etag = "etag-${request.clientSequence}") + } + } + + override suspend fun retire(request: MutationRetirement): MutationRetirementAck = + MutationRetirementAck( + minOf( + request.retiredThroughSequence, + retirementConfirmationCeiling ?: request.retiredThroughSequence, + ), + ) +} + +private class RecordingRestartWriteHandle : StoreWriteHandle { + val applied = mutableListOf>() + val confirmed = mutableListOf>() + + override suspend fun apply( + key: RestartKey, + value: String, + ) { + applied += key.canonicalId() to value + } + + override suspend fun markStale(key: RestartKey) = Unit + + override suspend fun confirmFresh( + key: RestartKey, + etag: String?, + ) { + confirmed += key.canonicalId() to etag + } +} + +private class StrictRecordingStringCodec( + private val supportedVersions: Set, + private val encodeVersion: Int? = null, +) : MutationCodec { + val encodedBuffers = mutableListOf() + val decodeVersions = mutableListOf() + val decodeInputs = mutableListOf() + + override fun encode(value: String): ByteArray = + (encodeVersion?.let { version -> "store6-codec-$version:$value" } ?: value) + .encodeToByteArray() + .also(encodedBuffers::add) + + override fun decode( + version: Int, + bytes: ByteArray, + ): String { + decodeVersions += version + decodeInputs += bytes + require(version in supportedVersions) { + "unsupported codec version $version\nIllegalArgumentException raw marker" + } + val decoded = bytes.decodeToString() + if (encodeVersion == null && !decoded.startsWith("store6-codec-")) return decoded + val expectedPrefix = "store6-codec-$version:" + require(decoded.startsWith(expectedPrefix)) { + "codec payload does not match decoder version $version" + } + return decoded.removePrefix(expectedPrefix) + } +} + +private data class AttemptSnapshot( + val clientId: String, + val sequence: Long, + val generation: Int, + val identity: Pair, + val codecVersion: Int, + val basePresence: MutationPresenceState, + val baseBlob: List?, + val minePresence: MutationPresenceState, + val mineBlob: List?, + val preconditionMetaPresent: Boolean, + val preconditionWrittenAt: Long?, + val preconditionEtag: String?, + val advertisedPrefix: Long, + val idempotencyKey: String, + val preparedAt: Long, + val conflictMetaPresent: Boolean?, + val conflictWrittenAt: Long?, + val conflictEtag: String?, + val conflictReceivedAt: Long?, +) + +private fun MutationAttemptRecord.snapshot(): AttemptSnapshot = + AttemptSnapshot( + clientId = clientId, + sequence = clientSequence, + generation = generation, + identity = effectiveNamespace to effectiveCanonicalId, + codecVersion = valueCodecVersion, + basePresence = basePresence, + baseBlob = baseBlob?.toList(), + minePresence = minePresence, + mineBlob = mineBlob?.toList(), + preconditionMetaPresent = preconditionMetaPresent, + preconditionWrittenAt = preconditionWrittenAt, + preconditionEtag = preconditionEtag, + advertisedPrefix = advertisedRetiredThroughSequence, + idempotencyKey = generationIdempotencyKey, + preparedAt = preparedAt, + conflictMetaPresent = conflictMetaPresent, + conflictWrittenAt = conflictWrittenAt, + conflictEtag = conflictEtag, + conflictReceivedAt = conflictReceivedAt, + ) + +private data class PushSnapshot( + val identity: Pair, + val clientId: String, + val sequence: Long, + val retiredPrefix: Long, + val mutationId: String, + val generation: Int, + val idempotencyKey: String, + val codecVersion: Int, + val base: String?, + val mine: String?, + val metaWrittenAt: Long?, + val metaEtag: String?, +) + +private fun MutationPush.snapshot(): PushSnapshot = + PushSnapshot( + identity = identity.namespace to identity.canonicalId, + clientId = clientId, + sequence = clientSequence, + retiredPrefix = retiredThroughSequence, + mutationId = mutationId, + generation = generation, + idempotencyKey = idempotencyKey, + codecVersion = valueCodecVersion, + base = (base as? MutationPresence.Present)?.value, + mine = (mine as? MutationPresence.Present)?.value, + metaWrittenAt = baseMeta?.writtenAtEpochMillis, + metaEtag = baseMeta?.etag, + ) + +private data class EffectSnapshot( + val index: Int, + val kind: MutationEffectKind, + val namespace: String, + val canonicalId: String?, + val createdAt: Long, + val disposition: MutationEffectDisposition = MutationEffectDisposition.PENDING, + val completedAt: Long? = null, +) + +private fun StoredEffectRecord.snapshot(): EffectSnapshot = + EffectSnapshot( + index = effectIndex, + kind = kind, + namespace = namespace, + canonicalId = canonicalId, + createdAt = createdAt, + disposition = disposition, + completedAt = completedAt, + ) + +private data class AliasSnapshot( + val sourceId: String, + val targetId: String, + val state: MutationAliasState, + val creatorClientId: String, + val creatorSequence: Long, + val createdAt: Long, + val activatedAt: Long?, +) + +private fun MutationKeyAliasRecord.snapshot(): AliasSnapshot = + AliasSnapshot( + sourceId = sourceCanonicalId, + targetId = targetCanonicalId, + state = state, + creatorClientId = createdByClientId, + creatorSequence = createdBySequence, + createdAt = createdAt, + activatedAt = activatedAt, + ) + +private data class TombstoneSnapshot( + val creatorSequence: Long, + val state: MutationTombstoneState, + val createdAt: Long, + val activatedAt: Long?, + val supersededByClientId: String?, + val supersededBySequence: Long?, + val supersededAt: Long?, +) + +private fun MutationKeyTombstoneRecord.snapshot(): TombstoneSnapshot = + TombstoneSnapshot( + creatorSequence = createdBySequence, + state = state, + createdAt = createdAt, + activatedAt = activatedAt, + supersededByClientId = supersededByClientId, + supersededBySequence = supersededBySequence, + supersededAt = supersededAt, + ) + +private data class PendingSnapshot( + val namespace: String, + val canonicalId: String, + val mutationId: String, + val mutatorId: String, + val state: MutationPendingState, + val attempt: Int, + val createdAt: Long, +) + +private fun PendingIntent.snapshot(): PendingSnapshot = + PendingSnapshot(namespace, canonicalId, mutationId, mutatorId, state, attempt, createdAtEpochMillis) + +private data class DeadLetterSnapshot( + val namespace: String, + val canonicalId: String, + val mutationId: String, + val mutatorId: String, + val generation: Int, + val attempts: Int, + val failureKind: MutationFailureKind, + val failureDetail: String, + val failureMessage: String, + val failureAt: Long, + val parkedAt: Long, +) + +private fun DeadLetter.snapshot(): DeadLetterSnapshot = + DeadLetterSnapshot( + namespace = namespace, + canonicalId = canonicalId, + mutationId = mutationId, + mutatorId = mutatorId, + generation = generation, + attempts = attempts, + failureKind = failure.kind, + failureDetail = failure.detail, + failureMessage = failure.message, + failureAt = failure.occurredAtEpochMillis, + parkedAt = parkedAtEpochMillis, + ) + +private data class InspectionSnapshot( + val pending: List, + val keyed: Map>, + val deadLetters: List, +) + +private suspend fun phaseKeyedSnapshots( + store: MutationStore, +): Map> = + (1..8).associate { sequence -> + val id = "phase-$sequence" + val namespace = + when (sequence) { + 3, 4, 5, 6 -> StoreNamespace("inspection-$sequence") + else -> StoreNamespace("mutations") + } + id to store.pending(RestartKey(id, "inspection", namespace)).map(PendingIntent::snapshot) + } + +private suspend fun seedEveryExecutionPhase(storage: MutationJournalStorage) { + storage.transaction { transaction -> + seedClient(transaction, lastAllocatedSequence = 8L) + seedExecution(transaction, 1L, StoredPhase.UNPREPARED) + seedExecution(transaction, 2L, StoredPhase.READY) + seedExecution(transaction, 3L, StoredPhase.INFLIGHT, namespace = "inspection-3") + seedExecution(transaction, 4L, StoredPhase.REFRESH_REQUIRED, namespace = "inspection-4") + seedExecution(transaction, 5L, StoredPhase.ACKED, namespace = "inspection-5") + seedExecution(transaction, 6L, StoredPhase.EFFECTS_PENDING, namespace = "inspection-6") + seedExecution(transaction, 7L, StoredPhase.PARKED) + seedExecution(transaction, 8L, StoredPhase.RETIRED) + } +} + +private suspend fun seedParkedFailure(storage: MutationJournalStorage) { + storage.transaction { transaction -> + seedClient(transaction, lastAllocatedSequence = 1L) + seedExecution(transaction, 1L, StoredPhase.PARKED) + } +} + +private fun seedClient( + transaction: MutationJournalTransaction, + lastAllocatedSequence: Long, +) { + seedNamedClient(transaction, "client-0", lastAllocatedSequence) +} + +private fun seedNamedClient( + transaction: MutationJournalTransaction, + clientId: String, + lastAllocatedSequence: Long, +) { + transaction.insertClient( + MutationClientRecord(1, clientId, 0L, 0L, 0L, createdAt = 10L), + ) + transaction.advanceClient( + MutationClientRecord( + 1, + clientId, + lastAllocatedSequence, + 0L, + 0L, + createdAt = 10L, + ), + ) +} + +private fun seedAcknowledgedIntent( + transaction: MutationJournalTransaction, + clientId: String, + sequence: Long, + presence: MutationPresenceState, + retire: Boolean = false, +) { + val mutationId = "$clientId-mutation-$sequence" + val isPresent = presence == MutationPresenceState.PRESENT + transaction.insertIntent( + recordVersion = 1, + clientId = clientId, + clientSequence = sequence, + mutationId = mutationId, + namespace = "mutations", + canonicalId = "entity", + mutatorId = if (isPresent) "upsert" else "delete", + mutatorVersion = 1, + argsBlob = if (isPresent) "+present".encodeToByteArray() else ByteArray(0), + idempotencyRoot = "$clientId:$sequence", + createdAt = 100L + sequence, + ) + transaction.insertExecution( + MutationExecutionRecord(clientId, sequence, StoredPhase.UNPREPARED, 0, 0, null, null, null), + ) + val authoritativeBlob = if (isPresent) "authoritative-$clientId-$sequence".encodeToByteArray() else null + transaction.insertAttempt( + MutationAttemptRecord( + clientId = clientId, + clientSequence = sequence, + generation = 1, + effectiveNamespace = "mutations", + effectiveCanonicalId = "entity", + valueCodecVersion = 1, + basePresence = MutationPresenceState.ABSENT, + baseBlob = null, + minePresence = presence, + mineBlob = authoritativeBlob, + preconditionMetaPresent = false, + preconditionWrittenAt = null, + preconditionEtag = null, + advertisedRetiredThroughSequence = 0L, + generationIdempotencyKey = "$clientId:$sequence:g1", + preparedAt = 200L + sequence, + conflictMetaPresent = null, + conflictWrittenAt = null, + conflictEtag = null, + conflictReceivedAt = null, + ), + ) + val ready = + MutationExecutionRecord(clientId, sequence, StoredPhase.READY, 1, 0, null, null, null) + transaction.advanceExecution(ready) + transaction.advanceExecution(ready.copyPhase(StoredPhase.INFLIGHT)) + transaction.insertAck( + MutationAckRecord( + clientId = clientId, + clientSequence = sequence, + generation = 1, + authoritativePresence = presence, + authoritativeBlob = authoritativeBlob, + valueCodecVersion = 1, + etag = "etag-$clientId-$sequence", + canonicalTargetNamespace = null, + canonicalTargetId = null, + receivedAt = 300L + sequence, + ), + ) + val acknowledged = + MutationExecutionRecord( + clientId, + sequence, + StoredPhase.ACKED, + 1, + 1, + 300L + sequence, + null, + null, + ) + transaction.advanceExecution(acknowledged) + if (retire) { + transaction.advanceExecution(acknowledged.copyPhase(StoredPhase.EFFECTS_PENDING)) + transaction.advanceExecution( + MutationExecutionRecord( + clientId, + sequence, + StoredPhase.RETIRED, + 1, + 1, + 300L + sequence, + null, + 400L + sequence, + ), + ) + } +} + +private fun tombstone( + canonicalId: String, + creatorClientId: String, + creatorSequence: Long, + state: MutationTombstoneState, + createdAt: Long, + activatedAt: Long? = null, + supersededByClientId: String? = null, + supersededBySequence: Long? = null, + supersededAt: Long? = null, +): MutationKeyTombstoneRecord = + MutationKeyTombstoneRecord( + namespace = "mutations", + canonicalId = canonicalId, + createdByClientId = creatorClientId, + createdBySequence = creatorSequence, + state = state, + createdAt = createdAt, + activatedAt = activatedAt, + supersededByClientId = supersededByClientId, + supersededBySequence = supersededBySequence, + supersededAt = supersededAt, + ) + +private fun insertActiveTombstone( + transaction: MutationJournalTransaction, + canonicalId: String, + creatorClientId: String, + creatorSequence: Long, + createdAt: Long, + activatedAt: Long, +) { + transaction.insertTombstone( + tombstone( + canonicalId, + creatorClientId, + creatorSequence, + MutationTombstoneState.PENDING, + createdAt, + ), + ) + transaction.advanceTombstone( + tombstone( + canonicalId, + creatorClientId, + creatorSequence, + MutationTombstoneState.ACTIVE, + createdAt, + activatedAt = activatedAt, + ), + ) +} + +private fun supersedeTombstone( + transaction: MutationJournalTransaction, + canonicalId: String, + creatorClientId: String, + creatorSequence: Long, + createdAt: Long, + activatedAt: Long, + supersededByClientId: String, + supersededBySequence: Long, + supersededAt: Long, +) { + transaction.advanceTombstone( + tombstone( + canonicalId, + creatorClientId, + creatorSequence, + MutationTombstoneState.SUPERSEDED, + createdAt, + activatedAt = activatedAt, + supersededByClientId = supersededByClientId, + supersededBySequence = supersededBySequence, + supersededAt = supersededAt, + ), + ) +} + +private fun insertActiveAlias( + transaction: MutationJournalTransaction, + source: String, + target: String, + creatorClientId: String, + creatorSequence: Long, +) { + val pending = + MutationKeyAliasRecord( + sourceNamespace = "mutations", + sourceCanonicalId = source, + targetNamespace = "mutations", + targetCanonicalId = target, + state = MutationAliasState.PENDING, + createdByClientId = creatorClientId, + createdBySequence = creatorSequence, + createdAt = 10L, + activatedAt = null, + ) + transaction.insertAlias(pending) + transaction.advanceAlias( + MutationKeyAliasRecord( + sourceNamespace = pending.sourceNamespace, + sourceCanonicalId = pending.sourceCanonicalId, + targetNamespace = pending.targetNamespace, + targetCanonicalId = pending.targetCanonicalId, + state = MutationAliasState.ACTIVE, + createdByClientId = pending.createdByClientId, + createdBySequence = pending.createdBySequence, + createdAt = pending.createdAt, + activatedAt = 11L, + ), + ) +} + +private suspend fun hydrateAndReadTombstones( + storage: MutationJournalStorage, + canonicalId: String, +): List { + return openRestartStore(storage, restartMutators()).use { reopened -> + reopened.pendingWrites() + reopened.tombstonesForInspection("mutations", canonicalId) + .map(MutationKeyTombstoneRecord::snapshot) + } +} + +private fun seedUnpreparedIntent( + transaction: MutationJournalTransaction, + sequence: Long, + mutationId: String, + canonicalId: String, + mutatorId: String, + mutatorVersion: Int, + argsBlob: ByteArray, +) { + transaction.insertIntent( + recordVersion = 1, + clientId = "client-0", + clientSequence = sequence, + mutationId = mutationId, + namespace = "mutations", + canonicalId = canonicalId, + mutatorId = mutatorId, + mutatorVersion = mutatorVersion, + argsBlob = argsBlob, + idempotencyRoot = "client-0:$sequence", + createdAt = 100L + sequence, + ) + transaction.insertExecution( + MutationExecutionRecord( + clientId = "client-0", + clientSequence = sequence, + phase = StoredPhase.UNPREPARED, + currentGeneration = 0, + attempt = 0, + lastAttemptAt = null, + activeFailureId = null, + retiredAt = null, + ), + ) +} + +private suspend fun seedCodecExecution( + storage: MutationJournalStorage, + target: StoredPhase, + valueCodecVersion: Int, + mutatorId: String = "upsert", +) { + require(target == StoredPhase.READY || target == StoredPhase.ACKED) + storage.transaction { transaction -> + seedClient(transaction, lastAllocatedSequence = 1L) + seedUnpreparedIntent( + transaction, + sequence = 1L, + mutationId = "mutation-1", + canonicalId = "codec-seed", + mutatorId = mutatorId, + mutatorVersion = 1, + argsBlob = "+mine".encodeToByteArray(), + ) + transaction.insertAttempt( + MutationAttemptRecord( + clientId = "client-0", + clientSequence = 1L, + generation = 1, + effectiveNamespace = "mutations", + effectiveCanonicalId = "codec-seed", + valueCodecVersion = valueCodecVersion, + basePresence = MutationPresenceState.PRESENT, + baseBlob = "confirmed".encodeToByteArray(), + minePresence = MutationPresenceState.PRESENT, + mineBlob = "confirmed+mine".encodeToByteArray(), + preconditionMetaPresent = false, + preconditionWrittenAt = null, + preconditionEtag = null, + advertisedRetiredThroughSequence = 0L, + generationIdempotencyKey = "client-0:1:g1", + preparedAt = 200L, + conflictMetaPresent = null, + conflictWrittenAt = null, + conflictEtag = null, + conflictReceivedAt = null, + ), + ) + val ready = + MutationExecutionRecord( + clientId = "client-0", + clientSequence = 1L, + phase = StoredPhase.READY, + currentGeneration = 1, + attempt = 0, + lastAttemptAt = null, + activeFailureId = null, + retiredAt = null, + ) + transaction.advanceExecution(ready) + if (target == StoredPhase.ACKED) { + transaction.advanceExecution(ready.copyPhase(StoredPhase.INFLIGHT)) + transaction.insertAck( + MutationAckRecord( + clientId = "client-0", + clientSequence = 1L, + generation = 1, + authoritativePresence = MutationPresenceState.PRESENT, + authoritativeBlob = "authoritative".encodeToByteArray(), + valueCodecVersion = valueCodecVersion, + etag = "etag-1", + canonicalTargetNamespace = null, + canonicalTargetId = null, + receivedAt = 300L, + ), + ) + transaction.advanceExecution( + MutationExecutionRecord( + clientId = "client-0", + clientSequence = 1L, + phase = StoredPhase.ACKED, + currentGeneration = 1, + attempt = 1, + lastAttemptAt = 300L, + activeFailureId = null, + retiredAt = null, + ), + ) + } + } +} + +private fun seedExecution( + transaction: MutationJournalTransaction, + sequence: Long, + target: StoredPhase, + namespace: String = "mutations", +) { + transaction.insertIntent( + recordVersion = 1, + clientId = "client-0", + clientSequence = sequence, + mutationId = "mutation-$sequence", + namespace = namespace, + canonicalId = "phase-$sequence", + mutatorId = "upsert", + mutatorVersion = 1, + argsBlob = "+$sequence".encodeToByteArray(), + idempotencyRoot = "client-0:$sequence", + createdAt = 100L + sequence, + ) + var execution = + MutationExecutionRecord( + "client-0", + sequence, + StoredPhase.UNPREPARED, + 0, + 0, + null, + null, + null, + ) + transaction.insertExecution(execution) + if (target == StoredPhase.UNPREPARED) return + if (target == StoredPhase.PARKED) { + val failure = + transaction.appendFailure( + clientId = "client-0", + clientSequence = sequence, + generation = 0, + kind = MutationFailureKind.CODEC, + detail = "codec-version", + message = "unsupported durable codec", + occurredAt = 700L + sequence, + ) + transaction.advanceExecution( + MutationExecutionRecord( + "client-0", + sequence, + StoredPhase.PARKED, + 0, + 0, + null, + failure.failureId, + null, + ), + ) + return + } + + val attempt = seededAttempt(sequence, namespace) + transaction.insertAttempt(attempt) + execution = + MutationExecutionRecord( + "client-0", + sequence, + StoredPhase.READY, + 1, + 0, + null, + null, + null, + ) + transaction.advanceExecution(execution) + if (target == StoredPhase.READY) return + + execution = execution.copyPhase(StoredPhase.INFLIGHT) + transaction.advanceExecution(execution) + if (target == StoredPhase.INFLIGHT) return + if (target == StoredPhase.REFRESH_REQUIRED) { + transaction.recordConflictReceipt( + MutationAttemptRecord( + clientId = attempt.clientId, + clientSequence = attempt.clientSequence, + generation = attempt.generation, + effectiveNamespace = attempt.effectiveNamespace, + effectiveCanonicalId = attempt.effectiveCanonicalId, + valueCodecVersion = attempt.valueCodecVersion, + basePresence = attempt.basePresence, + baseBlob = attempt.baseBlob, + minePresence = attempt.minePresence, + mineBlob = attempt.mineBlob, + preconditionMetaPresent = attempt.preconditionMetaPresent, + preconditionWrittenAt = attempt.preconditionWrittenAt, + preconditionEtag = attempt.preconditionEtag, + advertisedRetiredThroughSequence = attempt.advertisedRetiredThroughSequence, + generationIdempotencyKey = attempt.generationIdempotencyKey, + preparedAt = attempt.preparedAt, + conflictMetaPresent = false, + conflictWrittenAt = null, + conflictEtag = null, + conflictReceivedAt = 500L + sequence, + ), + ) + transaction.advanceExecution( + MutationExecutionRecord( + "client-0", + sequence, + StoredPhase.REFRESH_REQUIRED, + 1, + 1, + 500L + sequence, + null, + null, + ), + ) + return + } + + transaction.insertAck(seededAck(sequence)) + execution = + MutationExecutionRecord( + "client-0", + sequence, + StoredPhase.ACKED, + 1, + 1, + 500L + sequence, + null, + null, + ) + transaction.advanceExecution(execution) + if (target == StoredPhase.ACKED) return + + execution = execution.copyPhase(StoredPhase.EFFECTS_PENDING) + transaction.advanceExecution(execution) + if (target == StoredPhase.EFFECTS_PENDING) return + + check(target == StoredPhase.RETIRED) + transaction.advanceExecution( + MutationExecutionRecord( + "client-0", + sequence, + StoredPhase.RETIRED, + 1, + 1, + 500L + sequence, + null, + 900L + sequence, + ), + ) +} + +private fun seededAttempt( + sequence: Long, + namespace: String = "mutations", +): MutationAttemptRecord = + MutationAttemptRecord( + clientId = "client-0", + clientSequence = sequence, + generation = 1, + effectiveNamespace = namespace, + effectiveCanonicalId = "phase-$sequence", + valueCodecVersion = 1, + basePresence = MutationPresenceState.ABSENT, + baseBlob = null, + minePresence = MutationPresenceState.PRESENT, + mineBlob = "+$sequence".encodeToByteArray(), + preconditionMetaPresent = false, + preconditionWrittenAt = null, + preconditionEtag = null, + advertisedRetiredThroughSequence = 0L, + generationIdempotencyKey = "client-0:$sequence:g1", + preparedAt = 200L + sequence, + conflictMetaPresent = null, + conflictWrittenAt = null, + conflictEtag = null, + conflictReceivedAt = null, + ) + +private fun seededAck(sequence: Long): MutationAckRecord = + MutationAckRecord( + clientId = "client-0", + clientSequence = sequence, + generation = 1, + authoritativePresence = MutationPresenceState.PRESENT, + authoritativeBlob = "+$sequence".encodeToByteArray(), + valueCodecVersion = 1, + etag = "etag-$sequence", + canonicalTargetNamespace = null, + canonicalTargetId = null, + receivedAt = 500L + sequence, + ) + +private fun MutationExecutionRecord.copyPhase(phase: StoredPhase): MutationExecutionRecord = + MutationExecutionRecord( + clientId, + clientSequence, + phase, + currentGeneration, + attempt, + lastAttemptAt, + activeFailureId, + retiredAt, + ) + +private class InjectedPersistenceFailure : RuntimeException("injected journal write failure") + +private class ArmableFailingStorage( + private val delegate: MutationJournalStorage, +) : MutationJournalStorage { + var failNextReadyAdvance: Boolean = false + + override suspend fun transaction(block: (MutationJournalTransaction) -> R): R = + delegate.transaction { transaction -> + block( + object : MutationJournalTransaction by transaction { + override fun advanceExecution(record: MutationExecutionRecord) { + if (failNextReadyAdvance && record.phase == StoredPhase.READY) { + failNextReadyAdvance = false + throw InjectedPersistenceFailure() + } + transaction.advanceExecution(record) + } + }, + ) + } +} + +private class ArmablePhaseFailingStorage( + private val delegate: MutationJournalStorage, +) : MutationJournalStorage { + var failNextPhase: StoredPhase? = null + + override suspend fun transaction(block: (MutationJournalTransaction) -> R): R = + delegate.transaction { transaction -> + block( + object : MutationJournalTransaction by transaction { + override fun advanceExecution(record: MutationExecutionRecord) { + if (record.phase == failNextPhase) { + failNextPhase = null + throw InjectedPersistenceFailure() + } + transaction.advanceExecution(record) + } + }, + ) + } +} diff --git a/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournalTest.kt b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournalTest.kt new file mode 100644 index 000000000..85f0e915e --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournalTest.kt @@ -0,0 +1,305 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, +) + +package org.mobilenativefoundation.store6.mutations + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertSame +import kotlin.time.Duration.Companion.seconds + +class MutationJournalTest { + @Test + fun append_thenPendingSnapshot_returnsIntentInOrder() = runTest { + val journal = InMemoryMutationJournal() + val firstKey = MutationsTestKey("first") + val secondKey = MutationsTestKey("second") + + journal.append(firstKey.identity(), JournalEntry("mutation-1", "append", "one")) + journal.append(secondKey.identity(), JournalEntry("mutation-2", "append", "isolated")) + journal.append(firstKey.identity(), JournalEntry("mutation-3", "append", "three")) + + assertEquals( + listOf("mutation-1", "mutation-3"), + journal + .pendingSnapshot(firstKey.identity()) + .map(JournalEntry::mutationId), + ) + assertEquals( + listOf("mutation-2"), + journal + .pendingSnapshot(secondKey.identity()) + .map(JournalEntry::mutationId), + ) + } + + @Test + fun retire_removesIntent_andLeavesSiblingsPending() = runTest { + val journal = InMemoryMutationJournal() + val key = MutationsTestKey("retire") + journal.append(key.identity(), JournalEntry("mutation-1", "append", "one")) + journal.append(key.identity(), JournalEntry("mutation-2", "append", "two")) + journal.append(key.identity(), JournalEntry("mutation-3", "append", "three")) + + journal.retire(key.identity(), "mutation-2") + + assertEquals( + listOf("mutation-1", "mutation-3"), + journal + .pendingSnapshot(key.identity()) + .map(JournalEntry::mutationId), + ) + } + + @Test + fun pendingSnapshot_isNonSuspending_andReflectsLatestAppend() = runTest { + val journal = InMemoryMutationJournal() + val key = MutationsTestKey("snapshot") + journal.append(key.identity(), JournalEntry("mutation-1", "append", "one")) + + assertEquals( + listOf("mutation-1"), + readPendingFromNonSuspendingContext(journal, key.identity()) + .map(JournalEntry::mutationId), + ) + } + + @Test + fun hostileMutator_neverEscapesProjectAll() = runTest { + lateinit var hostile: MutatorRef + val registry = + mutatorRegistry { + hostile = + mutator( + id = "hostile", + version = 1, + codec = inertArgsCodec(), + stales = noStales(), + ) { _, _ -> throw IllegalStateException("boom") } + } + val engine = MutationEngine(registry, echoingMutationServer()) + val key = MutationsTestKey("hostile") + engine.mutate(key, hostile, Unit) + + assertEquals("base", engine.projectAll(key, "base")) + } + + @Test + fun hostileMutator_isRecordedAsPoisoned() = runTest { + val failure = IllegalStateException("boom") + lateinit var hostile: MutatorRef + val registry = + mutatorRegistry { + hostile = + mutator( + id = "hostile", + version = 1, + codec = inertArgsCodec(), + stales = noStales(), + ) { _, _ -> throw failure } + } + val engine = MutationEngine(registry, echoingMutationServer()) + val key = MutationsTestKey("poison") + val mutationId = engine.mutate(key, hostile, Unit) + + assertEquals("base", engine.projectAll(key, "base")) + val poisoned = engine.poisoned.first() + assertEquals(mutationId, poisoned.mutationId) + assertEquals(hostile.id, poisoned.mutatorId) + assertSame(failure, poisoned.failure) + } + + @Test + fun mutatorThrownCancellation_isAlsoContained() = runTest { + val failure = CancellationException("cancelled by user projection") + lateinit var cancelling: MutatorRef + val registry = + mutatorRegistry { + cancelling = + mutator( + id = "cancelling", + version = 1, + codec = inertArgsCodec(), + stales = noStales(), + ) { _, _ -> throw failure } + } + val engine = MutationEngine(registry, echoingMutationServer()) + val key = MutationsTestKey("cancellation") + val mutationId = engine.mutate(key, cancelling, Unit) + + assertEquals("base", engine.projectAll(key, "base")) + val poisoned = engine.poisoned.first() + assertEquals(mutationId, poisoned.mutationId) + assertEquals(cancelling.id, poisoned.mutatorId) + assertSame(failure, poisoned.failure) + } + + @Test + fun unknownMutatorId_isSkipped_notFatal() = runTest { + val journal = InMemoryMutationJournal() + val registry = mutatorRegistry {} + val engine = MutationEngine(registry, echoingMutationServer(), journal) + val key = MutationsTestKey("unknown") + journal.append(key.identity(), JournalEntry("mutation-1", "missing", Unit)) + + assertEquals("base", engine.projectAll(key, "base")) + } + + @Test + fun foreignMutatorRef_withAbsentId_isRejectedBeforeJournalAppend() = runTest { + lateinit var foreign: MutatorRef + mutatorRegistry { + foreign = + mutator( + id = "foreign", + version = 1, + codec = inertArgsCodec(), + stales = noStales(), + ) { base, _ -> base } + } + val journal = InMemoryMutationJournal() + val engine = + MutationEngine( + registry = mutatorRegistry {}, + server = echoingMutationServer(), + journal = journal, + ) + val key = MutationsTestKey("foreign-absent") + + val failure = + assertFailsWith { + engine.mutate(key, foreign, Unit) + } + + assertEquals( + "MutatorRef 'foreign' belongs to a different MutatorRegistry.", + failure.message, + ) + assertEquals(emptyList(), journal.pendingSnapshot(key.identity())) + } + + @Test + fun foreignMutatorRef_withCollidingId_isRejectedBeforeJournalAppend() = runTest { + lateinit var foreign: MutatorRef + mutatorRegistry { + foreign = + mutator( + id = "shared", + version = 1, + codec = inertArgsCodec(), + stales = noStales(), + ) { base, amount -> + (base as? MutationPresence.Present)?.let { + MutationPresence.Present(it.value + amount) + } + } + } + val targetRegistry = + mutatorRegistry { + mutator( + id = "shared", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { base, suffix -> + (base as? MutationPresence.Present)?.let { + MutationPresence.Present(it.value + suffix) + } + } + } + val journal = InMemoryMutationJournal() + val engine = MutationEngine(targetRegistry, echoingMutationServer(), journal) + val key = MutationsTestKey("foreign-collision") + + val failure = + assertFailsWith { + engine.mutate(key, foreign, 1) + } + + assertEquals( + "MutatorRef 'shared' belongs to a different MutatorRegistry.", + failure.message, + ) + assertEquals(emptyList(), journal.pendingSnapshot(key.identity())) + } + + @Test + fun duplicateMutatorId_withDifferentArgType_isRejectedBeforeOverwrite() = runTest { + val builder = MutatorRegistryBuilder() + val original = + builder.mutator( + id = "shared", + version = 1, + codec = inertArgsCodec(), + stales = noStales(), + ) { base, amount -> + MutationPresence.Present( + ((base as? MutationPresence.Present)?.value).orEmpty() + amount, + ) + } + + val failure = + assertFailsWith { + builder.mutator( + id = "shared", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { base, suffix -> + MutationPresence.Present( + ((base as? MutationPresence.Present)?.value).orEmpty() + suffix, + ) + } + } + + assertEquals("Mutator id 'shared' is already registered.", failure.message) + val engine = MutationEngine(builder.build(), echoingMutationServer()) + val key = MutationsTestKey("duplicate") + engine.mutate(key, original, 7) + assertEquals("base7", engine.projectAll(key, "base")) + } + + @Test + fun escapedBuilder_cannotRegisterAfterRegistryBuild() = runTest { + lateinit var escaped: MutatorRegistryBuilder + mutatorRegistry { + escaped = this + } + + val registrationFailure = + assertFailsWith { + escaped.mutator( + id = "late", + version = 1, + codec = inertArgsCodec(), + stales = noStales(), + ) { base, _ -> base } + } + + assertEquals( + "MutatorRegistryBuilder is already built.", + registrationFailure.message, + ) + val buildFailure = + assertFailsWith { + escaped.build() + } + assertEquals("MutatorRegistryBuilder is already built.", buildFailure.message) + } +} + +private fun readPendingFromNonSuspendingContext( + journal: MutationJournal, + key: KeyIdentity, +): List> = journal.pendingSnapshot(key) + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = 25.seconds, testBody = testBody) diff --git a/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationKeyResolverTest.kt b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationKeyResolverTest.kt new file mode 100644 index 000000000..730de8d65 --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationKeyResolverTest.kt @@ -0,0 +1,307 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, +) + +package org.mobilenativefoundation.store6.mutations + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.seam.StoreWriteHandle +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +/** + * The required resolver is global drain's correctness path. Every test drives the engine's own + * resolution — cache cleared where reconstruction must be forced — and proves exact-pair + * validation happens before any transport work. Durable parking of an unresolved pre-ack + * identity is covered by `MutationDrainParkingTest`, restart hydration by + * `MutationJournalContractTest`. + */ +class MutationKeyResolverTest { + @Test + fun exactNamespaceAndCanonicalId_isAccepted() = runTest { + val probe = ProbeMutation() + val server = ProbeServer() + val resolverCalls = mutableListOf>() + val engine = + MutationEngine( + registry = probe.registry, + server = server, + keyResolver = + MutationKeyResolver { identity -> + resolverCalls += identity.namespace to identity.canonicalId + ProbeKey(identity.namespace, identity.canonicalId) + }, + baseReader = { "base" }, + ) + engine.bind(ProbeNoopHandle) + engine.mutate(ProbeKey("probe", "entity-1"), probe.ref, "next") + engine.clearLiveKeyCache() + + engine.drain() + + assertEquals(listOf("probe" to "entity-1"), resolverCalls) + assertEquals(1, server.pushes.size) + assertEquals("probe", server.pushes.single().identity.namespace) + assertEquals("entity-1", server.pushes.single().identity.canonicalId) + assertEquals(emptyList(), engine.pending(ProbeKey("probe", "entity-1"))) + assertEquals(emptyList(), engine.drainFailuresForInspection()) + } + + @Test + fun nullResolution_isRejectedBeforeTransport() = runTest { + val probe = ProbeMutation() + val server = ProbeServer() + val engine = + MutationEngine( + registry = probe.registry, + server = server, + keyResolver = MutationKeyResolver { null }, + baseReader = { "base" }, + ) + engine.bind(ProbeNoopHandle) + val key = ProbeKey("probe", "entity-null") + val mutationId = engine.mutate(key, probe.ref, "next") + engine.clearLiveKeyCache() + + engine.drain() + + assertEquals(emptyList(), server.pushes) + assertEquals( + listOf(mutationId), + engine.pending(key).map(PendingIntent::mutationId), + ) + val failure = engine.drainFailuresForInspection().single() + assertEquals(MutationFailureKind.IDENTITY, failure.kind) + assertEquals(DRAIN_FAILURE_DETAIL_RESOLVER_NULL, failure.detail) + assertTrue(failure.message.contains("entity-null")) + } + + @Test + fun throwingResolution_isNormalizedWithoutRetainingThrowable() = runTest { + val probe = ProbeMutation() + val server = ProbeServer() + val engine = + MutationEngine( + registry = probe.registry, + server = server, + keyResolver = + MutationKeyResolver { + throw IllegalStateException( + "resolver blew up\n\tat frame.one(Resolver.kt:1)\n\tat frame.two", + ) + }, + baseReader = { "base" }, + ) + engine.bind(ProbeNoopHandle) + val key = ProbeKey("probe", "entity-throw") + val mutationId = engine.mutate(key, probe.ref, "next") + engine.clearLiveKeyCache() + + engine.drain() + + assertEquals(emptyList(), server.pushes) + assertEquals( + listOf(mutationId), + engine.pending(key).map(PendingIntent::mutationId), + ) + val failure = engine.drainFailuresForInspection().single() + assertEquals(MutationFailureKind.IDENTITY, failure.kind) + assertEquals(DRAIN_FAILURE_DETAIL_RESOLVER_THROW, failure.detail) + // First line only, controls stripped: the carrier holds sanitized strings, never the + // original Throwable, its frames, or its cause chain (D3/D14). + assertEquals("resolver blew up", failure.message) + assertFalse(failure.message.contains('\n')) + assertFalse(failure.message.any(Char::isISOControl)) + } + + @Test + fun resolverCancellation_rethrowsWithoutRecordingFailure() = runTest { + val probe = ProbeMutation() + val server = ProbeServer() + val engine = + MutationEngine( + registry = probe.registry, + server = server, + keyResolver = MutationKeyResolver { throw CancellationException("resolver cancelled") }, + baseReader = { "base" }, + ) + engine.bind(ProbeNoopHandle) + val key = ProbeKey("probe", "entity-cancel") + val mutationId = engine.mutate(key, probe.ref, "next") + engine.clearLiveKeyCache() + + val failure = + assertFailsWith { + engine.drain() + } + + assertEquals("resolver cancelled", failure.message) + assertEquals(emptyList(), server.pushes) + assertEquals(emptyList(), engine.drainFailuresForInspection()) + assertEquals( + listOf(mutationId), + engine.pending(key).map(PendingIntent::mutationId), + ) + } + + @Test + fun namespaceMismatch_isRejected() = runTest { + val probe = ProbeMutation() + val server = ProbeServer() + val engine = + MutationEngine( + registry = probe.registry, + server = server, + keyResolver = MutationKeyResolver { identity -> ProbeKey("other", identity.canonicalId) }, + baseReader = { "base" }, + ) + engine.bind(ProbeNoopHandle) + val key = ProbeKey("probe", "entity-2") + val mutationId = engine.mutate(key, probe.ref, "next") + engine.clearLiveKeyCache() + + engine.drain() + + assertEquals(emptyList(), server.pushes) + assertEquals( + listOf(mutationId), + engine.pending(key).map(PendingIntent::mutationId), + ) + val failure = engine.drainFailuresForInspection().single() + assertEquals(MutationFailureKind.IDENTITY, failure.kind) + assertEquals(DRAIN_FAILURE_DETAIL_IDENTITY_MISMATCH, failure.detail) + } + + @Test + fun canonicalIdMismatch_isRejected() = runTest { + val probe = ProbeMutation() + val server = ProbeServer() + val engine = + MutationEngine( + registry = probe.registry, + server = server, + keyResolver = MutationKeyResolver { identity -> ProbeKey(identity.namespace, "different") }, + baseReader = { "base" }, + ) + engine.bind(ProbeNoopHandle) + val key = ProbeKey("probe", "entity-3") + val mutationId = engine.mutate(key, probe.ref, "next") + engine.clearLiveKeyCache() + + engine.drain() + + assertEquals(emptyList(), server.pushes) + assertEquals( + listOf(mutationId), + engine.pending(key).map(PendingIntent::mutationId), + ) + val failure = engine.drainFailuresForInspection().single() + assertEquals(MutationFailureKind.IDENTITY, failure.kind) + assertEquals(DRAIN_FAILURE_DETAIL_IDENTITY_MISMATCH, failure.detail) + } + + @Test + fun cachedKey_isRevalidatedBeforeReuse() = runTest { + val probe = ProbeMutation() + val server = ProbeServer() + val resolverCalls = mutableListOf>() + val engine = + MutationEngine( + registry = probe.registry, + server = server, + keyResolver = + MutationKeyResolver { identity -> + resolverCalls += identity.namespace to identity.canonicalId + ProbeKey(identity.namespace, identity.canonicalId) + }, + baseReader = { "base" }, + ) + engine.bind(ProbeNoopHandle) + val key = ProbeKey("probe", "entity-4") + engine.mutate(key, probe.ref, "next") + // The enqueue seeded the live cache with this exact key object. Drift its canonical id: + // the cached K no longer reproduces the durable pair, so reuse without revalidation + // would address the wrong entity. + key.id = "drifted" + + engine.drain() + + // Revalidation rejected the drifted cache entry and the resolver reconstructed the key + // from the durable identity pair; the push addressed the original identity. + assertEquals(listOf("probe" to "entity-4"), resolverCalls) + assertEquals(1, server.pushes.size) + assertEquals("entity-4", server.pushes.single().identity.canonicalId) + assertEquals(emptyList(), engine.drainFailuresForInspection()) + } +} + +/** Single-file probe key with a configurable namespace and a deliberately mutable id. */ +private class ProbeKey( + private val namespaceValue: String, + var id: String, +) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace(namespaceValue) + + override fun canonicalId(): String = id +} + +private class ProbeMutation { + lateinit var ref: MutatorRef + val registry: MutatorRegistry = + mutatorRegistry { + ref = + mutator( + id = "rename", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { _, value -> MutationPresence.Present(value) } + } +} + +private class ProbeServer : MutationServer { + val pushes = mutableListOf>() + + override suspend fun push(request: MutationPush): MutationAck { + pushes += request + return when (val mine = request.mine) { + is MutationPresence.Present -> + MutationPresentAck( + authoritative = mine.value, + etag = "etag-${pushes.size}", + canonicalKey = null, + ) + MutationPresence.Absent -> MutationAbsentAck(etag = "etag-${pushes.size}") + } + } + + override suspend fun retire(request: MutationRetirement): MutationRetirementAck = + MutationRetirementAck(confirmedThroughSequence = request.retiredThroughSequence) +} + +private object ProbeNoopHandle : StoreWriteHandle { + override suspend fun apply( + key: ProbeKey, + value: String, + ) = Unit + + override suspend fun markStale(key: ProbeKey) = Unit + + override suspend fun confirmFresh( + key: ProbeKey, + etag: String?, + ) = Unit +} + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = 25.seconds, testBody = testBody) diff --git a/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationOverlayReplayPropertyTest.kt b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationOverlayReplayPropertyTest.kt new file mode 100644 index 000000000..36d885b1b --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationOverlayReplayPropertyTest.kt @@ -0,0 +1,672 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, +) + +package org.mobilenativefoundation.store6.mutations + +import app.cash.turbine.ReceiveTurbine +import app.cash.turbine.test +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.store +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import org.mobilenativefoundation.store6.mutations.storage.InMemoryMutationJournalStorage +import org.mobilenativefoundation.store6.mutations.storage.MutationAckRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationAttemptRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationClientRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionPhase as StoredExecutionPhase +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationJournalStorage +import org.mobilenativefoundation.store6.testing.FakeSourceOfTruth +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +class MutationOverlayReplayPropertyTest { + @Test + fun overlayProjection_isDeterministicUnderReplayPermutationsOfConfirmedInterleavings() = + runTest { + val fixture = replayFixture() + val cases = fixedReplayCases() + generatedReplayCases() + + cases.forEachIndexed { caseIndex, case -> + val schedules = confirmedInterleavings(case) + var canonical: Map? = null + schedules.forEach { schedule -> + val keys = case.bases.keys.associateWith { keyId -> MutationsTestKey("property-$keyId") } + val storage = InMemoryMutationJournalStorage() + val first = openReplayEngine(storage, fixture.registry) + case.intents.forEach { intent -> + val key = keys.getValue(intent.keyId) + when (intent.operation) { + ModelOperation.APPEND -> first.mutate(key, fixture.append, intent.token) + ModelOperation.PREPEND -> first.mutate(key, fixture.prepend, intent.token) + ModelOperation.REPLACE -> first.mutate(key, fixture.replace, intent.token) + ModelOperation.DELETE -> first.mutate(key, fixture.delete, intent.token) + } + } + schedule.forEach { sequence -> retireReplaySequence(storage, sequence) } + + val reopened = openReplayEngine(storage, fixture.registry) + reopened.ensureHydrated() + val actual = + keys.mapValues { (keyId, key) -> + reopened.overlay.apply(key, case.bases.getValue(keyId)) + } + val expected = keys.keys.associateWith { keyId -> expectedProjection(case, keyId) } + val context = + "masterSeed=$MASTER_REPLAY_SEED caseSeed=${case.seed} " + + "caseIndex=$caseIndex schedule=$schedule case=$case" + assertEquals(expected, actual, context) + canonical?.let { firstResult -> assertEquals(firstResult, actual, context) } + ?: run { canonical = actual } + } + } + } + + @Test + fun identityDefaultOverlay_isNoOp_onFallbackPath() = + runTest { + var plainFetches = 0 + var mutationFetches = 0 + val plainSource = FakeSourceOfTruth() + val mutationSource = FakeSourceOfTruth() + val plain = + store { + fetcher { "v${++plainFetches}" } + persistence(plainSource) + } + val server = PropertyCountingServer(authoritative = "unused") + val mutations = + mutationStore( + registry = mutatorRegistry { }, + server = server, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + ) { + fetcher { "v${++mutationFetches}" } + persistence(mutationSource) + journalStorage(InMemoryMutationJournalStorage()) + } + val key = MutationsTestKey("identity-fallback") + + try { + val plainSequence = + recordPropertyLifecycle(plain, key, terminal = "v2") { + plain.invalidate(key) + } + val mutationSequence = + recordPropertyLifecycle(mutations, key, terminal = "v2") { + mutations.invalidate(key) + } + + assertEquals(plainSequence, mutationSequence) + assertSame(mutationSource, mutations.sourceOfTruthRetainedByEngine) + assertEquals(0, server.pushCount) + assertEquals(emptyList(), mutations.pending(key)) + } finally { + plain.close() + mutations.close() + } + } + + @Test + fun retiredIntent_neverReprojectsOldBase_onFallbackPath() = + runTest { + val storage = InMemoryMutationJournalStorage() + val source = CountingPlainSourceOfTruth() + val fixture = fallbackAppendFixture() + val server = + PropertyCountingServer( + authoritative = "v2", + retirementConfirmationCeiling = 0L, + ) + val key = MutationsTestKey("retired-fallback") + val first = openFallbackStore(storage, source, fixture.registry, server) + + try { + first.stream(key).test { + awaitPropertyData { data -> data.value == "v1" && data.origin != Origin.OVERLAY } + first.mutate(key, fixture.append, "+op") + awaitPropertyData { data -> data.value == "v1+op" && data.origin == Origin.OVERLAY } + cancelAndIgnoreRemainingEvents() + } + + first.drain(key) + + assertEquals("v2", source.reader(key).first { value -> value == "v2" }) + assertEquals(emptyList(), first.pending(key)) + assertEquals(1, server.pushCount) + assertEquals(1, source.writes.count { (_, value) -> value == "v2" }) + assertEquals( + StoredExecutionPhase.RETIRED, + storage.transaction { it.executions(FALLBACK_CLIENT_ID).single().phase }, + ) + } finally { + first.close() + } + + val reopened = openFallbackStore(storage, source, fixture.registry, server) + try { + reopened.stream(key, Freshness.LocalOnly).test { + while (true) { + val item = awaitItem() + if (item is StoreResult.Data) { + assertTrue(item.value != "v1" && item.value != "v1+op") + if (item.value == "v2") { + assertTrue(item.origin == Origin.SOT || item.origin == Origin.MEMORY) + assertTrue(item.origin != Origin.OVERLAY) + cancelAndIgnoreRemainingEvents() + break + } + } + } + } + reopened.drain(key) + assertEquals(1, server.pushCount) + assertEquals(1, source.writes.count { (_, value) -> value == "v2" }) + } finally { + reopened.close() + } + } +} + +private data class ReplayCase( + val seed: Long, + val bases: Map, + val intents: List, + val confirmedPrefixByKey: Map, +) + +private data class ModelIntent( + val keyId: Int, + val clientSequence: Long, + val operation: ModelOperation, + val token: String, +) + +private enum class ModelOperation { + APPEND, + PREPEND, + REPLACE, + DELETE, +} + +private class ReplayRng(seed: Long) { + private var state = seed + + fun nextInt(bound: Int): Int { + require(bound > 0) + state = state * 6364136223846793005L + 1442695040888963407L + return ((state ushr 1) % bound.toLong()).toInt() + } + + fun nextLong(): Long { + state = state * 6364136223846793005L + 1442695040888963407L + return state + } +} + +private data class ReplayFixture( + val registry: MutatorRegistry, + val append: MutatorRef, + val prepend: MutatorRef, + val replace: MutatorRef, + val delete: MutatorRef, +) + +private fun replayFixture(): ReplayFixture { + lateinit var append: MutatorRef + lateinit var prepend: MutatorRef + lateinit var replace: MutatorRef + lateinit var delete: MutatorRef + val registry = + mutatorRegistry { + append = + upsert("property-append", 1, FixtureStringArgsCodec, noStales()) { base, token -> + MutationPresence.Present(modelValue(base) + token) + } + prepend = + upsert("property-prepend", 1, FixtureStringArgsCodec, noStales()) { base, token -> + MutationPresence.Present(token + modelValue(base)) + } + replace = + upsert("property-replace", 1, FixtureStringArgsCodec, noStales()) { _, token -> + MutationPresence.Present(token) + } + delete = + upsert("property-delete", 1, FixtureStringArgsCodec, noStales()) { _, _ -> + MutationPresence.Absent + } + } + return ReplayFixture(registry, append, prepend, replace, delete) +} + +private fun modelValue(presence: MutationPresence): String = + (presence as? MutationPresence.Present)?.value ?: ABSENT_MARKER + +private fun fixedReplayCases(): List = + listOf( + ReplayCase( + seed = -1L, + bases = mapOf(0 to "base"), + intents = + listOf( + ModelIntent(0, 1L, ModelOperation.APPEND, "+a"), + ModelIntent(0, 2L, ModelOperation.PREPEND, "b+"), + ModelIntent(0, 3L, ModelOperation.REPLACE, "replacement"), + ModelIntent(0, 4L, ModelOperation.APPEND, "+tail"), + ), + confirmedPrefixByKey = mapOf(0 to 1), + ), + ReplayCase( + seed = -2L, + bases = mapOf(0 to null), + intents = + listOf( + ModelIntent(0, 1L, ModelOperation.REPLACE, "created"), + ModelIntent(0, 2L, ModelOperation.DELETE, "ignored"), + ModelIntent(0, 3L, ModelOperation.APPEND, "+after"), + ), + confirmedPrefixByKey = mapOf(0 to 1), + ), + ReplayCase( + seed = -3L, + bases = mapOf(0 to "a", 1 to "b", 2 to null), + intents = + listOf( + ModelIntent(0, 1L, ModelOperation.APPEND, "+1"), + ModelIntent(1, 2L, ModelOperation.PREPEND, "2+"), + ModelIntent(0, 3L, ModelOperation.REPLACE, "a3"), + ModelIntent(2, 4L, ModelOperation.APPEND, "+4"), + ModelIntent(1, 5L, ModelOperation.APPEND, "+5"), + ), + confirmedPrefixByKey = mapOf(0 to 2, 1 to 1, 2 to 1), + ), + ) + +private fun generatedReplayCases(): List { + val seeds = ReplayRng(MASTER_REPLAY_SEED) + return List(GENERATED_REPLAY_CASES) { + val caseSeed = seeds.nextLong() + generateReplayCase(caseSeed) + } +} + +private fun generateReplayCase(seed: Long): ReplayCase { + val random = ReplayRng(seed) + val keyCount = random.nextInt(3) + 1 + val bases = + (0 until keyCount).associateWith { keyId -> + if (random.nextInt(2) == 0) null else "base-$keyId-${random.nextInt(100)}" + } + val intentCount = random.nextInt(6) + val operations = ModelOperation.entries + val intents = + (1..intentCount).map { ordinal -> + ModelIntent( + keyId = random.nextInt(keyCount), + clientSequence = ordinal.toLong(), + operation = operations[random.nextInt(operations.size)], + token = "t$ordinal-${random.nextInt(100)}", + ) + } + val confirmed = + (0 until keyCount).associateWith { keyId -> + val count = intents.count { intent -> intent.keyId == keyId } + random.nextInt(count + 1) + } + return ReplayCase(seed, bases, intents, confirmed) +} + +private fun confirmedInterleavings(case: ReplayCase): List> { + val perKey = + case.bases.keys.map { keyId -> + case.intents + .filter { intent -> intent.keyId == keyId } + .take(case.confirmedPrefixByKey.getValue(keyId)) + .map { intent -> intent.clientSequence } + } + val positions = IntArray(perKey.size) + val current = mutableListOf() + val results = mutableListOf>() + + fun visit() { + var advanced = false + perKey.indices.forEach { keyIndex -> + val position = positions[keyIndex] + if (position < perKey[keyIndex].size) { + advanced = true + positions[keyIndex] += 1 + current += perKey[keyIndex][position] + visit() + current.removeAt(current.lastIndex) + positions[keyIndex] -= 1 + } + } + if (!advanced) results += current.toList() + } + + visit() + return results +} + +private fun expectedProjection( + case: ReplayCase, + keyId: Int, +): String? = + case.intents + .asSequence() + .filter { intent -> intent.keyId == keyId } + .drop(case.confirmedPrefixByKey.getValue(keyId)) + .sortedBy { intent -> intent.clientSequence } + .fold(case.bases.getValue(keyId)) { value, intent -> applyModel(value, intent) } + +private fun applyModel( + value: String?, + intent: ModelIntent, +): String? = + when (intent.operation) { + ModelOperation.APPEND -> (value ?: ABSENT_MARKER) + intent.token + ModelOperation.PREPEND -> intent.token + (value ?: ABSENT_MARKER) + ModelOperation.REPLACE -> intent.token + ModelOperation.DELETE -> null + } + +private fun openReplayEngine( + storage: MutationJournalStorage, + registry: MutatorRegistry, +): MutationEngine = + MutationEngine( + registry = registry, + server = erroringReplayServer(), + journal = + StorageBackedMutationJournal( + storage = storage, + registrations = registry.registrations, + clientId = REPLAY_CLIENT_ID, + hydrateOnFirstUse = true, + ), + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + clientId = REPLAY_CLIENT_ID, + ) + +private fun erroringReplayServer(): MutationServer = + object : MutationServer { + override suspend fun push( + request: MutationPush, + ): MutationAck = error("The pure replay property never pushes.") + + override suspend fun retire(request: MutationRetirement): MutationRetirementAck = + error("The pure replay property never checkpoints.") + } + +private suspend fun retireReplaySequence( + storage: MutationJournalStorage, + sequence: Long, +) { + storage.transaction { transaction -> + val intent = transaction.intents(REPLAY_CLIENT_ID).single { row -> row.clientSequence == sequence } + val execution = transaction.executions(REPLAY_CLIENT_ID).single { row -> row.clientSequence == sequence } + require(execution.phase == StoredExecutionPhase.UNPREPARED) + val preparedAt = 1_000L + sequence + transaction.insertAttempt( + MutationAttemptRecord( + clientId = REPLAY_CLIENT_ID, + clientSequence = sequence, + generation = 1, + effectiveNamespace = intent.namespace, + effectiveCanonicalId = intent.canonicalId, + valueCodecVersion = 1, + basePresence = MutationPresenceState.PRESENT, + baseBlob = "base-$sequence".encodeToByteArray(), + minePresence = MutationPresenceState.PRESENT, + mineBlob = "mine-$sequence".encodeToByteArray(), + preconditionMetaPresent = false, + preconditionWrittenAt = null, + preconditionEtag = null, + advertisedRetiredThroughSequence = 0L, + generationIdempotencyKey = "property-$sequence-g1", + preparedAt = preparedAt, + conflictMetaPresent = null, + conflictWrittenAt = null, + conflictEtag = null, + conflictReceivedAt = null, + ), + ) + transaction.advanceExecution( + execution.copyForReplay(StoredExecutionPhase.READY, generation = 1), + ) + transaction.advanceExecution( + execution.copyForReplay(StoredExecutionPhase.INFLIGHT, generation = 1), + ) + transaction.insertAck( + MutationAckRecord( + clientId = REPLAY_CLIENT_ID, + clientSequence = sequence, + generation = 1, + authoritativePresence = MutationPresenceState.PRESENT, + authoritativeBlob = "ack-$sequence".encodeToByteArray(), + valueCodecVersion = 1, + etag = "etag-$sequence", + canonicalTargetNamespace = null, + canonicalTargetId = null, + receivedAt = preparedAt + 1L, + ), + ) + transaction.advanceExecution( + execution.copyForReplay( + StoredExecutionPhase.ACKED, + generation = 1, + attempt = 1, + lastAttemptAt = preparedAt + 1L, + ), + ) + transaction.advanceExecution( + execution.copyForReplay( + StoredExecutionPhase.EFFECTS_PENDING, + generation = 1, + attempt = 1, + lastAttemptAt = preparedAt + 1L, + ), + ) + transaction.advanceExecution( + execution.copyForReplay( + StoredExecutionPhase.RETIRED, + generation = 1, + attempt = 1, + lastAttemptAt = preparedAt + 1L, + retiredAt = preparedAt + 2L, + ), + ) + + val client = requireNotNull(transaction.client(REPLAY_CLIENT_ID)) + val phases = transaction.executions(REPLAY_CLIENT_ID).associate { row -> row.clientSequence to row.phase } + var prefix = client.retiredThroughSequence + while (phases[prefix + 1L] == StoredExecutionPhase.RETIRED) prefix += 1L + if (prefix > client.retiredThroughSequence) { + transaction.advanceClient( + MutationClientRecord( + recordVersion = client.recordVersion, + clientId = client.clientId, + lastAllocatedSequence = client.lastAllocatedSequence, + retiredThroughSequence = prefix, + serverConfirmedRetiredThroughSequence = client.serverConfirmedRetiredThroughSequence, + createdAt = client.createdAt, + ), + ) + } + } +} + +private fun MutationExecutionRecord.copyForReplay( + phase: StoredExecutionPhase, + generation: Int, + attempt: Int = 0, + lastAttemptAt: Long? = null, + retiredAt: Long? = null, +): MutationExecutionRecord = + MutationExecutionRecord( + clientId = clientId, + clientSequence = clientSequence, + phase = phase, + currentGeneration = generation, + attempt = attempt, + lastAttemptAt = lastAttemptAt, + activeFailureId = null, + retiredAt = retiredAt, + ) + +private data class FallbackAppendFixture( + val registry: MutatorRegistry, + val append: MutatorRef, +) + +private fun fallbackAppendFixture(): FallbackAppendFixture { + lateinit var append: MutatorRef + val registry = + mutatorRegistry { + append = + upsert("fallback-append", 1, FixtureStringArgsCodec, noStales()) { base, suffix -> + val value = (base as? MutationPresence.Present)?.value.orEmpty() + MutationPresence.Present(value + suffix) + } + } + return FallbackAppendFixture(registry, append) +} + +private fun openFallbackStore( + storage: InMemoryMutationJournalStorage, + source: SourceOfTruth, + registry: MutatorRegistry, + server: MutationServer, +): MutationStore = + mutationStore( + registry = registry, + server = server, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + ) { + fetcher { "v1" } + persistence(source) + journalStorage(storage) + } + +private class PropertyCountingServer( + private val authoritative: String, + private val retirementConfirmationCeiling: Long? = null, +) : MutationServer { + var pushCount: Int = 0 + private set + + override suspend fun push( + request: MutationPush, + ): MutationAck { + pushCount += 1 + return MutationPresentAck(authoritative, etag = "property-etag", canonicalKey = null) + } + + override suspend fun retire(request: MutationRetirement): MutationRetirementAck = + MutationRetirementAck( + minOf( + request.retiredThroughSequence, + retirementConfirmationCeiling ?: request.retiredThroughSequence, + ), + ) +} + +private class CountingPlainSourceOfTruth( + private val delegate: FakeSourceOfTruth = FakeSourceOfTruth(), +) : SourceOfTruth { + val writes = mutableListOf>() + + override fun reader(key: K): Flow = delegate.reader(key) + + override suspend fun write( + key: K, + value: V, + ) { + writes += key to value + delegate.write(key, value) + } + + override suspend fun delete(key: K) = delegate.delete(key) + + override suspend fun deleteNamespace(namespace: StoreNamespace) = delegate.deleteNamespace(namespace) + + override suspend fun deleteAll() = delegate.deleteAll() +} + +private suspend fun recordPropertyLifecycle( + store: Store, + key: MutationsTestKey, + terminal: String, + script: suspend () -> Unit, +): List { + val recorded = mutableListOf() + var scriptRan = false + store.stream(key).test { + while (true) { + when (val item = awaitItem()) { + is StoreResult.Loading -> recorded += "loading" + is StoreResult.Data -> { + recorded += "data(${item.value},${item.origin},stale=${item.isStale})" + if (item.value == terminal) { + cancelAndIgnoreRemainingEvents() + break + } + if (!scriptRan) { + scriptRan = true + script() + } + } + is StoreResult.Revalidated -> recorded += "revalidated" + is StoreResult.Error -> recorded += "error" + } + } + } + return recorded +} + +private suspend fun ReceiveTurbine>.awaitPropertyData( + predicate: (StoreResult.Data) -> Boolean, +): StoreResult.Data { + while (true) { + val item = awaitItem() + if (item is StoreResult.Data && predicate(item)) return item + } +} + +private const val MASTER_REPLAY_SEED: Long = 0x0225_2026_0801L +private const val GENERATED_REPLAY_CASES: Int = 64 +private const val REPLAY_CLIENT_ID: String = "property-client" +private const val FALLBACK_CLIENT_ID: String = "client-0" +private const val ABSENT_MARKER: String = "" + +// Turbine's 3s default would nest inside the 25s shadow. Raising the Turbine deadline above +// the shadow makes runTest the only effective timeout. +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds // strictly > TEST_TIMEOUT: the shadow must fire first + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } diff --git a/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationOverlayTest.kt b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationOverlayTest.kt new file mode 100644 index 000000000..037d0ee4f --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationOverlayTest.kt @@ -0,0 +1,377 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, +) + +package org.mobilenativefoundation.store6.mutations + +import app.cash.turbine.test +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.store +import org.mobilenativefoundation.store6.core.seam.StoreWriteHandle +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +class MutationOverlayTest { + @Test + fun noPendingIntents_isIdentity_andPassesThroughEnvelope() = runTest { + val engine = + MutationEngine( + mutatorRegistry {}, + echoingMutationServer(), + ) + val key = MutationsTestKey("identity") + val base = charArrayOf('b', 'a', 's', 'e').concatToString() + + assertSame(base, engine.overlay.apply(key, base)) + } + + @Test + fun pendingIntent_projectsOverlayOrigin() = runTest { + lateinit var append: MutatorRef + val engine = + MutationEngine( + mutatorRegistry { + append = + mutator( + id = "append", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { base, suffix -> + MutationPresence.Present( + ((base as? MutationPresence.Present)?.value).orEmpty() + suffix, + ) + } + }, + echoingMutationServer(), + ) + val key = MutationsTestKey("active") + val store = store { + fetcher { "base" } + overlay(engine.overlay) + } + + try { + store.stream(key).test { + assertEquals("base", awaitData().value) + + engine.mutate(key, append, "+pending") + var projected = awaitData() + while (projected.value != "base+pending") { + projected = awaitData() + } + + assertEquals(Origin.OVERLAY, projected.origin) + assertEquals(Duration.ZERO, projected.age) + assertFalse(projected.isStale) + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun pendingCreate_overAbsentBase_projectsOptimisticCreate() = runTest { + lateinit var create: MutatorRef + val engine = + MutationEngine( + mutatorRegistry { + create = + mutator( + id = "create", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { _, value -> MutationPresence.Present(value) } + }, + echoingMutationServer(), + ) + val key = MutationsTestKey("create") + engine.mutate(key, create, "optimistic") + + assertEquals("optimistic", engine.overlay.apply(key, null)) + } + + @Test + fun pendingDelete_overResidentBase_projectsAbsence() = runTest { + lateinit var delete: MutatorRef + val engine = + MutationEngine( + mutatorRegistry { + delete = + mutator( + id = "delete", + version = 1, + codec = inertArgsCodec(), + stales = noStales(), + ) { _, _ -> MutationPresence.Absent } + }, + echoingMutationServer(), + ) + val key = MutationsTestKey("delete") + engine.mutate(key, delete, Unit) + + assertNull(engine.overlay.apply(key, "resident")) + } + + @Test + fun changes_toleratesConcurrentCollectors() = runTest { + lateinit var append: MutatorRef + val engine = + MutationEngine( + mutatorRegistry { + append = + mutator( + id = "append", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { base, suffix -> + MutationPresence.Present( + ((base as? MutationPresence.Present)?.value).orEmpty() + suffix, + ) + } + }, + echoingMutationServer(), + ) + val key = MutationsTestKey("fan-out") + val observed = List(CONCURRENT_COLLECTORS) { CompletableDeferred() } + val collectors = + observed.map { signal -> + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + signal.complete(engine.overlay.changes.first()) + } + } + + engine.mutate(key, append, "+pending") + + observed.forEach { signal -> + assertSame(key, signal.await()) + } + collectors.forEach { it.join() } + } + + @Test + fun cancelledMutate_afterAppendStillPublishesKeyChange() = runTest { + lateinit var rename: MutatorRef + val engine = + MutationEngine( + mutatorRegistry { + rename = + mutator( + id = "rename", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { _, value -> MutationPresence.Present(value) } + }, + echoingMutationServer(), + ) + val firstKey = MutationsTestKey("append-signal-blocker") + val cancelledKey = MutationsTestKey("append-signal-cancelled") + val firstObserved = CompletableDeferred() + val cancelledObserved = CompletableDeferred() + val collector = + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + var observed = 0 + engine.changes.collect { key -> + when (observed++) { + 0 -> firstObserved.complete(key) + 1 -> cancelledObserved.complete(key) + } + } + } + + try { + engine.mutate(firstKey, rename, "first") + val cancelledMutation = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.mutate(cancelledKey, rename, "second") + } + assertFalse(cancelledMutation.isCompleted) + assertEquals(1, engine.pending(cancelledKey).size) + + cancelledMutation.cancel() + testScheduler.runCurrent() + cancelledMutation.join() + + assertSame(firstKey, firstObserved.await()) + assertTrue(cancelledMutation.isCancelled) + assertTrue( + cancelledObserved.isCompleted, + "a committed append must retain its key-change handoff across cancellation", + ) + assertSame(cancelledKey, cancelledObserved.await()) + } finally { + collector.cancelAndJoin() + } + } + + @Test + fun cancelledDrain_afterRetireStillPublishesKeyChange() = runTest { + lateinit var rename: MutatorRef + val registry = + mutatorRegistry { + rename = + mutator( + id = "rename", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { _, value -> MutationPresence.Present(value) } + } + val journal = InMemoryMutationJournal() + val engine = MutationEngine(registry, echoingMutationServer(), journal, baseReader = { "base" }) + engine.bind(NoopWriteHandle) + val firstKey = MutationsTestKey("retire-signal-blocker") + val retiredKey = MutationsTestKey("retire-signal-cancelled") + val firstObserved = CompletableDeferred() + val retiredObserved = CompletableDeferred() + val collector = + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + var observed = 0 + engine.changes.collect { key -> + when (observed++) { + 0 -> firstObserved.complete(key) + 1 -> retiredObserved.complete(key) + } + } + } + + try { + engine.mutate(firstKey, rename, "first") + journal.append( + retiredKey.identity(), + JournalEntry( + mutationId = "retired-mutation", + mutatorId = rename.id, + args = "second", + ), + ) + val cancelledDrain = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.drain(retiredKey) + } + assertFalse(cancelledDrain.isCompleted) + assertEquals(emptyList(), engine.pending(retiredKey)) + + cancelledDrain.cancel() + testScheduler.runCurrent() + cancelledDrain.join() + + assertSame(firstKey, firstObserved.await()) + assertTrue(cancelledDrain.isCancelled) + assertTrue( + retiredObserved.isCompleted, + "a completed retirement must retain its key-change handoff across cancellation", + ) + assertSame(retiredKey, retiredObserved.await()) + } finally { + collector.cancelAndJoin() + } + } + + @Test + fun changes_neverCompletes_andNeverFails() = runTest { + lateinit var append: MutatorRef + val journal = InMemoryMutationJournal() + val engine = + MutationEngine( + registry = + mutatorRegistry { + append = + mutator( + id = "append", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { base, suffix -> + MutationPresence.Present( + ((base as? MutationPresence.Present)?.value).orEmpty() + suffix, + ) + } + }, + server = echoingMutationServer(), + journal = journal, + ) + val key = MutationsTestKey("lifecycle") + val store = store { + fetcher { "base" } + overlay(engine.overlay) + } + val baseObserved = CompletableDeferred() + val streamCollector = + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + store.stream(key).collect { result -> + if (result is StoreResult.Data && result.value == "base") { + baseObserved.complete(Unit) + } + } + } + + try { + baseObserved.await() + engine.overlay.changes.test { + val firstMutation = engine.mutate(key, append, "+first") + assertSame(key, awaitItem()) + + journal.retire(key.identity(), firstMutation) + store.close() + streamCollector.cancelAndJoin() + + engine.mutate(key, append, "+second") + assertSame(key, awaitItem()) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + streamCollector.cancelAndJoin() + } + } +} + +private object NoopWriteHandle : StoreWriteHandle { + override suspend fun apply( + key: MutationsTestKey, + value: String, + ) = Unit + + override suspend fun markStale(key: MutationsTestKey) = Unit + + override suspend fun confirmFresh( + key: MutationsTestKey, + etag: String?, + ) = Unit +} + +private const val CONCURRENT_COLLECTORS = 8 +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } diff --git a/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationPresenceTest.kt b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationPresenceTest.kt new file mode 100644 index 000000000..31200359f --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationPresenceTest.kt @@ -0,0 +1,101 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.mutations + +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.StoreNamespace +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.time.Duration.Companion.seconds + +class MutationPresenceTest { + @Test + fun presentAbsentAndDecline_areDistinctAndExhaustive() = runTest { + val present: MutationPresence = MutationPresence.Present("value") + val absent: MutationPresence = MutationPresence.Absent + val decline: MutationPresence? = null + + // Sealed exhaustiveness: no else branch compiles or is required. + val describedPresent = + when (present) { + is MutationPresence.Present -> "present:${present.value}" + MutationPresence.Absent -> "absent" + } + assertEquals("present:value", describedPresent) + + val describedAbsent = + when (absent) { + is MutationPresence.Present -> "present:${absent.value}" + MutationPresence.Absent -> "absent" + } + assertEquals("absent", describedAbsent) + + // Decline is null and only null; it is never Absent and never a value. + assertNull(decline) + assertNotNull(absent) + assertIs>(present) + } + + @Test + fun presence_isCovariant_andAbsentUnifiesAcrossValueTypes() = runTest { + // Declaration-site covariance: a Present is a MutationPresence. + val widened: MutationPresence = MutationPresence.Present("value") + assertIs>(widened) + + // Absent is one singleton usable at every value type. + val stringAbsent: MutationPresence = MutationPresence.Absent + val intAbsent: MutationPresence = MutationPresence.Absent + assertSame(stringAbsent, intAbsent) + } + + @Test + fun nullProjectorResult_meansDeclineOnly_neverDeletion() = runTest { + lateinit var declining: MutatorRef + lateinit var deleting: MutatorRef + val registry = + mutatorRegistry { + declining = + mutator( + id = "declining", + version = 1, + codec = Utf8StringCodec(), + stales = { _, _ -> emptyStaleSet() }, + ) { _, _ -> null } + deleting = delete("deleting") { _, _ -> emptyStaleSet() } + } + + val declined = + registry.registrations + .getValue(declining.id) + .project(MutationPresence.Present("base"), "args") + val deleted = + registry.registrations + .getValue(deleting.id) + .project(MutationPresence.Present("base"), Unit) + + // Null is decline and nothing else; deletion is the distinct non-null Absent. + assertNull(declined) + assertSame(MutationPresence.Absent, assertNotNull(deleted)) + } +} + +private class Utf8StringCodec : MutationCodec { + override fun encode(value: String): ByteArray = value.encodeToByteArray() + + override fun decode( + version: Int, + bytes: ByteArray, + ): String = bytes.decodeToString() +} + +private fun emptyStaleSet(): StaleSet = + StaleSet(keys = emptySet(), namespaces = emptySet()) + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = 25.seconds, testBody = testBody) diff --git a/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocolTest.kt b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocolTest.kt new file mode 100644 index 000000000..12b25a0b8 --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocolTest.kt @@ -0,0 +1,724 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, +) + +package org.mobilenativefoundation.store6.mutations + +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreMeta +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.seam.StoreWriteHandle +import kotlin.reflect.KProperty1 +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotSame +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlin.test.fail +import kotlin.time.Duration.Companion.seconds + +class MutationProtocolTest { + // The server signature receives the complete library-built carrier for a registered + // mutator. + @Test + fun serverReceivesLibraryBuiltPushWithCompleteRegisteredMutatorCarrier() = runTest { + lateinit var append: MutatorRef + val registry = + mutatorRegistry { + append = + mutator( + id = "append", + version = 1, + codec = RecordingStringCodec(), + stales = { _, _ -> emptyStaleSet() }, + ) { base, suffix -> + when (base) { + is MutationPresence.Present -> + MutationPresence.Present(base.value + suffix) + MutationPresence.Absent -> null + } + } + } + val mine = + registry.registrations + .getValue(append.id) + .project(MutationPresence.Present("base"), "-mine") + val projectedMine = assertIs>(mine) + val server = RecordingMutationServer() + val push = + libraryBuiltPush( + base = MutationPresence.Present("base"), + mine = projectedMine, + ) + + server.push(push) + + assertEquals(1, server.pushes.size) + val received = server.pushes.single() + assertSame(push, received) + assertEquals("mutations", received.identity.namespace) + assertEquals("entity-1", received.identity.canonicalId) + assertEquals("entity-1", received.key.canonicalId()) + assertEquals("client-1", received.clientId) + assertEquals(4L, received.clientSequence) + assertEquals(3L, received.retiredThroughSequence) + assertEquals("mutation-9", received.mutationId) + assertEquals(2, received.generation) + assertEquals("idempotency-2", received.idempotencyKey) + assertEquals(5, received.valueCodecVersion) + assertEquals("base-mine", projectedMine.value) + } + + @Test + fun valueCodecRoundTrip_preservesPresentBaseMineAndAck() = runTest { + val codec = RecordingStringCodec() + + val baseBlob = codec.encodeCopied("base-value") + val mineBlob = codec.encodeCopied("mine-value") + val ackBlob = codec.encodeCopied("authoritative-value") + + val base = MutationPresence.Present(codec.decodeCopied(1, baseBlob)) + val mine = MutationPresence.Present(codec.decodeCopied(1, mineBlob)) + val ack = MutationPresentAck( + authoritative = codec.decodeCopied(1, ackBlob), + etag = "etag-1", + canonicalKey = null, + ) + + assertEquals("base-value", base.value) + assertEquals("mine-value", mine.value) + assertEquals("authoritative-value", ack.authoritative) + } + + @Test + fun decodeReceivesPersistedValueCodecVersion() = runTest { + val codec = RecordingStringCodec() + val push = + libraryBuiltPush( + base = MutationPresence.Present("base"), + mine = MutationPresence.Present("mine"), + valueCodecVersion = 7, + ) + + codec.decodeCopied(push.valueCodecVersion, "stored".encodeToByteArray()) + + assertEquals(7, codec.lastDecodeVersion) + } + + @Test + fun valueCodecBuffers_areDefensivelyCopiedAtEncodeAndDecodeBoundaries() = runTest { + val codec = RecordingStringCodec() + + // Encode boundary: the retained array is a copy, so a codec (or producer) mutating the + // array it returned cannot corrupt stored bytes. + val stored = codec.encodeCopied("payload") + val returnedByCodec = assertIs(codec.lastEncodeResult) + assertNotSame(returnedByCodec, stored) + returnedByCodec.fill(0) + assertContentEquals("payload".encodeToByteArray(), stored) + + // Decode boundary: the codec receives a fresh copy, so mutating either array cannot + // corrupt the stored retry generation. + val decoded = codec.decodeCopied(1, stored) + assertEquals("payload", decoded) + val receivedByCodec = assertIs(codec.lastDecodeBytes) + assertNotSame(stored, receivedByCodec) + receivedByCodec.fill(0) + assertContentEquals("payload".encodeToByteArray(), stored) + } + + // Presence, never nullable V, crosses push/candidate/ack carriers. + @Test + fun pushBaseAndMine_neverUseNullableValue() = runTest { + // Compile-level: these property references type-check only while the carrier fields are + // non-nullable MutationPresence. + val pushBase: KProperty1, MutationPresence> = + MutationPush::base + val pushMine: KProperty1, MutationPresence> = + MutationPush::mine + val candidateBase: + KProperty1< + MutationPreconditionCandidate, + MutationPresence, + > = + MutationPreconditionCandidate::base + val ackValue: KProperty1, String> = + MutationPresentAck::authoritative + assertEquals("base", pushBase.name) + assertEquals("mine", pushMine.name) + assertEquals("base", candidateBase.name) + assertEquals("authoritative", ackValue.name) + + // Runtime: confirmed absence is the Absent value, not null. + val push = + libraryBuiltPush( + base = MutationPresence.Absent, + mine = MutationPresence.Present("mine"), + ) + assertSame(MutationPresence.Absent, push.base) + assertIs>(push.mine) + } + + @Test + fun pushExposesCompleteFrozenGenerationAndAuthoritativeIdentity() = runTest { + val meta = TestMeta(writtenAtEpochMillis = 42L, etag = "etag-precondition") + val push = + libraryBuiltPush( + base = MutationPresence.Present("base"), + mine = MutationPresence.Present("mine"), + baseMeta = meta, + ) + + assertEquals("mutations", push.identity.namespace) + assertEquals("entity-1", push.identity.canonicalId) + assertEquals("entity-1", push.key.canonicalId()) + assertEquals("client-1", push.clientId) + assertEquals(4L, push.clientSequence) + assertEquals(3L, push.retiredThroughSequence) + assertEquals("mutation-9", push.mutationId) + assertEquals(2, push.generation) + assertEquals("idempotency-2", push.idempotencyKey) + assertEquals(5, push.valueCodecVersion) + assertEquals("base", assertIs>(push.base).value) + assertEquals("mine", assertIs>(push.mine).value) + assertEquals(42L, assertIs(push.baseMeta).writtenAtEpochMillis) + assertEquals("etag-precondition", push.baseMeta?.etag) + } + + // Identity is the sole server-authoritative address; the resolved key is + // process-local adapter context whose non-identity representation may vary across retries. + @Test + fun pushIdentity_staysStable_whenResolvedKeyRepresentationChanges() = runTest { + val identity = MutationKeyIdentity(namespace = "mutations", canonicalId = "entity-1") + + fun retryWithResolvedKey(key: RepresentedKey): MutationPush = + MutationPush( + identity = identity, + key = key, + clientId = "client-1", + clientSequence = 4L, + retiredThroughSequence = 3L, + mutationId = "mutation-9", + generation = 2, + idempotencyKey = "idempotency-2", + valueCodecVersion = 5, + base = MutationPresence.Present("base"), + mine = MutationPresence.Present("mine"), + baseMeta = null, + ) + + val firstRetry = retryWithResolvedKey(RepresentedKey(id = "entity-1", revision = 1)) + val secondRetry = retryWithResolvedKey(RepresentedKey(id = "entity-1", revision = 2)) + + assertSame(identity, firstRetry.identity) + assertSame(identity, secondRetry.identity) + assertEquals(firstRetry.identity.namespace, secondRetry.identity.namespace) + assertEquals(firstRetry.identity.canonicalId, secondRetry.identity.canonicalId) + // The adapter-context representation changed; the authoritative address did not. + assertEquals(1, firstRetry.key.revision) + assertEquals(2, secondRetry.key.revision) + assertEquals(firstRetry.idempotencyKey, secondRetry.idempotencyKey) + } + + // The exact-pair validation the engine runs before transport; cause == null for a + // mismatch. + @Test + fun resolverIdentityMismatch_failsBeforeTransport() = runTest { + val identity = MutationKeyIdentity(namespace = "mutations", canonicalId = "expected") + val resolver = MutationKeyResolver { MutationsTestKey("different") } + val server = RecordingMutationServer() + + val resolved = resolver.resolve(identity) + val failure = + assertFailsWith { + requireResolvedKey(identity, resolved) + } + + assertNull(failure.cause) + assertTrue(server.pushes.isEmpty()) + assertTrue(server.retirements.isEmpty()) + } + + // The ENGINE rebuilds every retry of one semantic generation from defensively copied + // blobs. A server that mutates the delivered base/mine carrier cannot alter the next + // reconstruction, and the shared base instance the engine read stays untouched. Durable + // INFLIGHT exact-replay is proven by `MutationDrainResumabilityMatrixTest`. + @Test + fun semanticRetry_rebuildsEveryPushFieldFromDefensiveCopies() = runTest { + lateinit var rename: MutatorRef + val registry = + mutatorRegistry { + rename = + mutator( + id = "rename", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { _, next -> MutationPresence.Present(MutableValue(next)) } + } + val codec = RecordingMutableValueCodec() + val server = MutatingThenFailingServer() + val sharedBase = MutableValue("base") + val engine = + MutationEngine( + registry = registry, + server = server, + valueCodecVersion = 5, + valueCodec = codec, + baseReader = { sharedBase }, + ) + engine.bind(MutableValueNoopHandle) + val key = MutationsTestKey("semantic-retry") + engine.mutate(key, rename, "mine-value") + + // First attempt: the server mutates the delivered carrier's values, then fails. + engine.drain(key) + // Retry of the same semantic generation. + engine.drain(key) + + assertEquals(2, server.pushes.size) + val first = server.pushes[0] + val second = server.pushes[1] + // The server's mutation leaked nowhere: not into the engine's shared base instance and + // not into the retry's reconstruction. + assertEquals("base", sharedBase.text) + assertEquals( + "base", + assertIs>(second.base).value.text, + ) + assertEquals( + "mine-value", + assertIs>(second.mine).value.text, + ) + // The delivered instances are copies, never the engine's stored/projected objects. + assertNotSame( + sharedBase, + assertIs>(first.base).value, + ) + // Same semantic generation across the retry: identity, client identity/sequence, + // generation, deterministic idempotency key, and codec version all reconstruct equal. + assertEquals(first.identity.namespace, second.identity.namespace) + assertEquals(first.identity.canonicalId, second.identity.canonicalId) + assertEquals(first.clientId, second.clientId) + assertEquals(first.clientSequence, second.clientSequence) + assertEquals(first.mutationId, second.mutationId) + assertEquals(first.generation, second.generation) + assertEquals(first.idempotencyKey, second.idempotencyKey) + assertEquals("client-0:1:g1", second.idempotencyKey) + assertEquals(5, second.valueCodecVersion) + // The engine's copy boundary hands the codec the persisted version. + assertEquals(5, codec.lastDecodeVersion) + } + + // The acknowledged authoritative value is rebuilt through the codec's + // copy boundaries before adoption and echo-forward, so a server retaining its acknowledged + // object cannot mutate adopted state or a later push's base. + @Test + fun ackAuthoritative_isDefensivelyCopiedBeforeAdoptionAndEchoForward() = runTest { + lateinit var append: MutatorRef + val registry = + mutatorRegistry { + append = + mutator( + id = "append", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { base, suffix -> + MutationPresence.Present( + MutableValue( + ((base as? MutationPresence.Present)?.value?.text).orEmpty() + suffix, + ), + ) + } + } + val server = RetainingAckServer() + val applied = mutableListOf() + val engine = + MutationEngine( + registry = registry, + server = server, + valueCodecVersion = 5, + valueCodec = RecordingMutableValueCodec(), + baseReader = { null }, + ) + engine.bind( + object : StoreWriteHandle { + override suspend fun apply( + key: MutationsTestKey, + value: MutableValue, + ) { + applied += value + } + + override suspend fun markStale(key: MutationsTestKey) = Unit + + override suspend fun confirmFresh( + key: MutationsTestKey, + etag: String?, + ) = Unit + }, + ) + val key = MutationsTestKey("retained-ack") + engine.mutate(key, append, "+a") + engine.mutate(key, append, "+b") + + engine.drain(key) + + // The second push's base echoed the first acknowledgement through the copy boundary. + assertEquals(2, server.pushes.size) + assertEquals( + "auth-1", + assertIs>(server.pushes[1].base).value.text, + ) + + // The server now corrupts the authoritative object it retained; nothing it corrupted is + // the object the engine adopted or echoed. + server.retainedAcks.forEach { retained -> retained.text = "corrupted-by-server" } + assertEquals(listOf("auth-1", "auth-2"), applied.map(MutableValue::text)) + assertEquals( + "auth-1", + assertIs>(server.pushes[1].base).value.text, + ) + } + + // The sealed variants make a canonical target on confirmed absence unrepresentable. + @Test + fun absentAckHasNoCanonicalKeyAndPresentAckMayRekey() = runTest { + val rekeyed = + MutationPresentAck( + authoritative = "value", + etag = "etag-1", + canonicalKey = MutationsTestKey("canonical"), + ) + assertEquals("canonical", rekeyed.canonicalKey?.canonicalId()) + + val unchanged = + MutationPresentAck( + authoritative = "value", + etag = null, + canonicalKey = null, + ) + assertNull(unchanged.canonicalKey) + + // Compile-level: MutationAbsentAck's only constructable state is the etag. Exhaustive + // dispatch needs no else branch. + val absent: MutationAck = + MutationAbsentAck(etag = "etag-2") + when (absent) { + is MutationPresentAck -> fail("Absent acknowledgement matched the present variant.") + is MutationAbsentAck -> assertEquals("etag-2", absent.etag) + } + } + + // Library-side monotonic validation keeps the consumer-built carrier plain. + @Test + fun retirementAckRejectsRegressionAndPrefixAboveRequest() = runTest { + val request = MutationRetirement(clientId = "client-1", retiredThroughSequence = 10L) + + // A confirmation above the requested prefix is a protocol violation. + assertFailsWith { + validateRetirementAck( + request = request, + ack = MutationRetirementAck(confirmedThroughSequence = 12L), + previousConfirmedThroughSequence = 5L, + ) + } + + // A confirmation regressing below the persisted prefix is a protocol violation. + assertFailsWith { + validateRetirementAck( + request = request, + ack = MutationRetirementAck(confirmedThroughSequence = 3L), + previousConfirmedThroughSequence = 5L, + ) + } + + // Monotonic advancement and idempotent re-confirmation are both legal. + assertEquals( + 7L, + validateRetirementAck( + request = request, + ack = MutationRetirementAck(confirmedThroughSequence = 7L), + previousConfirmedThroughSequence = 5L, + ), + ) + assertEquals( + 5L, + validateRetirementAck( + request = request, + ack = MutationRetirementAck(confirmedThroughSequence = 5L), + previousConfirmedThroughSequence = 5L, + ), + ) + } + + // The capture candidate carries immutable captured metadata and nothing + // else — no selected baseMeta and no transport door. Any reference to `candidate.baseMeta` + // or a push/server member does not compile. + @Test + fun preconditionCandidateHasCapturedMetaButNoSelectedBaseMetaOrTransportDoor() = runTest { + val capturedMeta = TestMeta(writtenAtEpochMillis = 42L, etag = "captured") + val candidate = + MutationPreconditionCandidate( + identity = MutationKeyIdentity(namespace = "mutations", canonicalId = "entity-1"), + key = MutationsTestKey("entity-1"), + mutationId = "mutation-9", + generation = 2, + base = MutationPresence.Present("base"), + mine = MutationPresence.Present("mine"), + capturedMeta = capturedMeta, + ) + + assertEquals("mutations", candidate.identity.namespace) + assertEquals("entity-1", candidate.identity.canonicalId) + assertEquals("entity-1", candidate.key.canonicalId()) + assertEquals("mutation-9", candidate.mutationId) + assertEquals(2, candidate.generation) + assertEquals("base", assertIs>(candidate.base).value) + assertEquals("mine", assertIs>(candidate.mine).value) + assertSame(capturedMeta, assertIs(candidate.capturedMeta)) + + // Captured metadata may be absent without weakening the existence precondition. + val withoutMeta = + MutationPreconditionCandidate( + identity = MutationKeyIdentity(namespace = "mutations", canonicalId = "entity-1"), + key = MutationsTestKey("entity-1"), + mutationId = "mutation-9", + generation = 2, + base = MutationPresence.Absent, + mine = MutationPresence.Present("mine"), + capturedMeta = null, + ) + assertNull(withoutMeta.capturedMeta) + } + + // Carrier-level only; the ordered-capture behavioral tests live in `MutationAckPathTest`. + @Test + fun absentBaseIsExistencePreconditionNotUnconditionalWrite() = runTest { + val push = + libraryBuiltPush( + base = MutationPresence.Absent, + mine = MutationPresence.Present("mine"), + baseMeta = null, + ) + + // Absent base plus null metadata is still a precondition: apply only if still absent. + assertSame(MutationPresence.Absent, push.base) + assertNull(push.baseMeta) + } + + // Stable public enums expose their exact value sets. + @Test + fun stablePublicEnums_exposeExactRuledValueSets() = runTest { + assertEquals( + listOf("PRESENT", "ABSENT"), + MutationPresenceState.entries.map(MutationPresenceState::name), + ) + assertEquals( + listOf("PENDING", "INFLIGHT", "REFRESHING", "ADOPTING", "APPLYING_EFFECTS"), + MutationPendingState.entries.map(MutationPendingState::name), + ) + assertEquals( + listOf( + "IDENTITY", "CODEC", "PROJECTION", "PROTOCOL", "CONFLICT", + "TRANSPORT", "ADOPTION", "EFFECT", "PERSISTENCE", + ), + MutationFailureKind.entries.map(MutationFailureKind::name), + ) + } + + // The normalized failure carries no raw cause and honours the byte budgets. + @Test + fun normalizedFailure_carriesNoRawCauseAndHonoursSanitizationContract() = runTest { + val cause = IllegalStateException("boom at the transport boundary") + val failure = + sanitizedMutationFailure( + kind = MutationFailureKind.TRANSPORT, + detail = "transport\t-detail\nstack frame one", + message = cause.stackTraceToString(), + occurredAtEpochMillis = 7L, + ) + + assertEquals(MutationFailureKind.TRANSPORT, failure.kind) + assertEquals(7L, failure.occurredAtEpochMillis) + // Stack-trace lines and control characters never survive normalization. + assertFalse(failure.detail.contains('\n')) + assertFalse(failure.detail.any(Char::isISOControl)) + assertFalse(failure.message.contains('\n')) + assertFalse(failure.message.any(Char::isISOControl)) + assertEquals("transport-detail", failure.detail) + assertTrue(failure.detail.encodeToByteArray().size <= 128) + assertTrue(failure.message.encodeToByteArray().size <= 1024) + } +} + +private class RepresentedKey( + private val id: String, + val revision: Int, +) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("mutations") + + override fun canonicalId(): String = id +} + +private class TestMeta( + override val writtenAtEpochMillis: Long, + override val etag: String?, +) : StoreMeta + +private class RecordingStringCodec : MutationCodec { + var lastEncodeResult: ByteArray? = null + var lastDecodeVersion: Int? = null + var lastDecodeBytes: ByteArray? = null + + override fun encode(value: String): ByteArray = + value.encodeToByteArray().also { lastEncodeResult = it } + + override fun decode( + version: Int, + bytes: ByteArray, + ): String { + lastDecodeVersion = version + lastDecodeBytes = bytes + return bytes.decodeToString() + } +} + +private class RecordingMutationServer : MutationServer { + val pushes = mutableListOf>() + val retirements = mutableListOf() + + override suspend fun push( + request: MutationPush, + ): MutationAck { + pushes += request + return when (val mine = request.mine) { + is MutationPresence.Present -> + MutationPresentAck( + authoritative = mine.value, + etag = "etag-${pushes.size}", + canonicalKey = null, + ) + MutationPresence.Absent -> MutationAbsentAck(etag = "etag-${pushes.size}") + } + } + + override suspend fun retire(request: MutationRetirement): MutationRetirementAck { + retirements += request + return MutationRetirementAck(confirmedThroughSequence = request.retiredThroughSequence) + } +} + +private fun libraryBuiltPush( + identity: MutationKeyIdentity = + MutationKeyIdentity(namespace = "mutations", canonicalId = "entity-1"), + key: MutationsTestKey = MutationsTestKey("entity-1"), + base: MutationPresence, + mine: MutationPresence, + valueCodecVersion: Int = 5, + baseMeta: StoreMeta? = null, +): MutationPush = + MutationPush( + identity = identity, + key = key, + clientId = "client-1", + clientSequence = 4L, + retiredThroughSequence = 3L, + mutationId = "mutation-9", + generation = 2, + idempotencyKey = "idempotency-2", + valueCodecVersion = valueCodecVersion, + base = base, + mine = mine, + baseMeta = baseMeta, + ) + +private fun emptyStaleSet(): StaleSet = + StaleSet(keys = emptySet(), namespaces = emptySet()) + +/** A deliberately mutable value type: the engine's copy boundaries are what protect it. */ +private class MutableValue( + var text: String, +) + +private class RecordingMutableValueCodec : MutationCodec { + var lastDecodeVersion: Int? = null + + override fun encode(value: MutableValue): ByteArray = value.text.encodeToByteArray() + + override fun decode( + version: Int, + bytes: ByteArray, + ): MutableValue { + lastDecodeVersion = version + return MutableValue(bytes.decodeToString()) + } +} + +private object MutableValueNoopHandle : StoreWriteHandle { + override suspend fun apply( + key: MutationsTestKey, + value: MutableValue, + ) = Unit + + override suspend fun markStale(key: MutationsTestKey) = Unit + + override suspend fun confirmFresh( + key: MutationsTestKey, + etag: String?, + ) = Unit +} + +/** Mutates the first delivered carrier's values, then fails every push as transport. */ +private class MutatingThenFailingServer : MutationServer { + val pushes = mutableListOf>() + + override suspend fun push( + request: MutationPush, + ): MutationAck { + pushes += request + if (pushes.size == 1) { + (request.base as? MutationPresence.Present)?.value?.text = "mutated-by-server" + (request.mine as? MutationPresence.Present)?.value?.text = "mutated-by-server" + } + throw IllegalStateException("transport failed") + } + + override suspend fun retire(request: MutationRetirement): MutationRetirementAck = + MutationRetirementAck(confirmedThroughSequence = request.retiredThroughSequence) +} + +/** Acknowledges each push with an authoritative object it retains for later corruption. */ +private class RetainingAckServer : MutationServer { + val pushes = mutableListOf>() + val retainedAcks = mutableListOf() + + override suspend fun push( + request: MutationPush, + ): MutationAck { + pushes += request + val authoritative = MutableValue("auth-${pushes.size}") + retainedAcks += authoritative + return MutationPresentAck( + authoritative = authoritative, + etag = "etag-${pushes.size}", + canonicalKey = null, + ) + } + + override suspend fun retire(request: MutationRetirement): MutationRetirementAck = + MutationRetirementAck(confirmedThroughSequence = request.retiredThroughSequence) +} + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = 25.seconds, testBody = testBody) diff --git a/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationPruningRegressionTest.kt b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationPruningRegressionTest.kt new file mode 100644 index 000000000..26d66ee99 --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationPruningRegressionTest.kt @@ -0,0 +1,864 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, +) + +package org.mobilenativefoundation.store6.mutations + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.seam.StoreWriteHandle +import org.mobilenativefoundation.store6.mutations.storage.InMemoryMutationJournalStorage +import org.mobilenativefoundation.store6.mutations.storage.MutationAckRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationAliasState +import org.mobilenativefoundation.store6.mutations.storage.MutationAttemptRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationClientRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationEffectDisposition +import org.mobilenativefoundation.store6.mutations.storage.MutationEffectKind +import org.mobilenativefoundation.store6.mutations.storage.MutationEffectRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionPhase as StoredExecutionPhase +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationJournalStorage +import org.mobilenativefoundation.store6.mutations.storage.MutationJournalTransaction +import org.mobilenativefoundation.store6.mutations.storage.MutationKeyAliasRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationKeyTombstoneRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationTombstoneState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +class MutationPruningRegressionTest { + @Test + fun ackAndPendingAliasCommit_isAtomicAcrossCrash() = + runTest(timeout = 25.seconds) { + val rawStorage = InMemoryMutationJournalStorage() + val killedStorage = EngineKillPointJournalStorage(rawStorage) + val fixture = pruningFixture() + val source = MutationsTestKey("provisional-ack") + val canonical = MutationsTestKey("canonical-ack") + val server = + CountingAckServer( + canonicalKey = canonical, + retirementConfirmationCeiling = 0L, + ) + val adoption = CountingAdoptionHandle() + val first = openPruningEngine(killedStorage, fixture.registry, server, adoption) + + first.mutate(source, fixture.append, "+mine") + killedStorage.arm(EngineJournalKillPoint.AFTER_ALIAS_INSERT_COMMIT) + val crash = + assertFailsWith { + first.drain(source) + } + assertEquals(EngineJournalKillPoint.AFTER_ALIAS_INSERT_COMMIT, crash.killPoint) + assertEquals(true, crash.aliasCommitIncludedAckAndAcked) + assertEquals(1, server.pushCount) + assertEquals(0, adoption.applyCount) + assertEquals(0, adoption.confirmFreshCount) + + rawStorage.transaction { transaction -> + assertEquals( + StoredExecutionPhase.ACKED, + transaction.executions(PRUNING_CLIENT_ID).single().phase, + ) + assertEquals(1, transaction.acks(PRUNING_CLIENT_ID).size) + assertEquals(MutationAliasState.PENDING, transaction.aliases().single().state) + } + + openPruningEngine(rawStorage, fixture.registry, server, adoption).drain(source) + assertEquals(1, server.pushCount) + assertEquals(1, adoption.applyCount) + assertEquals(1, adoption.confirmFreshCount) + rawStorage.transaction { transaction -> + assertEquals( + StoredExecutionPhase.RETIRED, + transaction.executions(PRUNING_CLIENT_ID).single().phase, + ) + assertEquals(MutationAliasState.ACTIVE, transaction.aliases().single().state) + } + } + + @Test + fun crashBetweenRetireAndPrune_neverPermitsDoubleApply() = + runTest(timeout = 25.seconds) { + val rawStorage = InMemoryMutationJournalStorage() + val killedStorage = EngineKillPointJournalStorage(rawStorage) + val fixture = pruningFixture() + val server = CountingAckServer() + val adoption = CountingAdoptionHandle() + val key = MutationsTestKey("retire-prune") + val first = openPruningEngine(killedStorage, fixture.registry, server, adoption) + + first.mutate(key, fixture.append, "+mine") + killedStorage.arm(EngineJournalKillPoint.AFTER_RETIREMENT_FINALIZATION_COMMIT) + val crash = + assertFailsWith { + first.drain(key) + } + assertEquals(EngineJournalKillPoint.AFTER_RETIREMENT_FINALIZATION_COMMIT, crash.killPoint) + assertEquals(1, server.pushCount) + assertEquals(1, adoption.applyCount) + assertEquals(1, adoption.confirmFreshCount) + rawStorage.transaction { transaction -> + assertEquals(StoredExecutionPhase.RETIRED, transaction.executions(PRUNING_CLIENT_ID).single().phase) + assertEquals(1L, requireNotNull(transaction.client(PRUNING_CLIENT_ID)).retiredThroughSequence) + } + + openPruningEngine(rawStorage, fixture.registry, server, adoption).drain(key) + assertEquals(1, server.pushCount) + assertEquals(1, adoption.applyCount) + assertEquals(1, adoption.confirmFreshCount) + + rawStorage.transaction { transaction -> + transaction.confirmRetiredThrough( + clientId = PRUNING_CLIENT_ID, + requestedThroughSequence = 1L, + serverConfirmedThroughSequence = 1L, + ) + transaction.prune(PRUNING_CLIENT_ID, serverConfirmedRetiredThroughSequence = 1L) + } + openPruningEngine(rawStorage, fixture.registry, server, adoption).drain(key) + assertEquals(1, server.pushCount) + assertEquals(1, adoption.applyCount) + assertEquals(1, adoption.confirmFreshCount) + } + + /** + * Ordinary prune removes rows only at or below the persisted server-confirmed prefix; + * alias redirects and active or pending tombstone generations always survive. + */ + @Test + fun pruneNeverExceedsServerConfirmedPrefix_evenAfterCrashLoop() = + runTest(timeout = 25.seconds) { + var storage: MutationJournalStorage = InMemoryMutationJournalStorage() + seedPrunableJournal(storage) + val protected = storage.transaction(::protectedJournalSnapshot) + + listOf( + EngineJournalKillPoint.BEFORE_PRUNE, + EngineJournalKillPoint.BEFORE_PRUNE_COMMIT, + EngineJournalKillPoint.AFTER_PRUNE_COMMIT, + ).forEach { point -> + val killed = EngineKillPointJournalStorage(storage) + killed.arm(point) + val crash = + assertFailsWith { + killed.transaction { transaction -> + transaction.prune( + PRUNING_CLIENT_ID, + serverConfirmedRetiredThroughSequence = 2L, + ) + } + } + assertEquals(point, crash.killPoint) + storage = reopenInMemory(storage) + assertEquals(protected, storage.transaction(::protectedJournalSnapshot), "after $point") + assertClientInvariant(storage) + } + + storage.transaction { transaction -> + transaction.prune(PRUNING_CLIENT_ID, serverConfirmedRetiredThroughSequence = 2L) + } + assertEquals(protected, storage.transaction(::protectedJournalSnapshot), "after final prune") + assertClientInvariant(storage) + } + + @Test + fun killPointBeforeRetirementCommit_leavesEffectsPendingAndNoRevision() = + runTest(timeout = 25.seconds) { + val composedStorage = InMemoryMutationJournalStorage() + seedEffectsPendingJournal(composedStorage) + val killedComposition = EngineKillPointJournalStorage(composedStorage) + killedComposition.arm(EngineJournalKillPoint.BEFORE_RETIREMENT_FINALIZATION_COMMIT) + + val compositionCrash = + assertFailsWith { + finalizeSeededRetirement(killedComposition) + } + assertEquals( + EngineJournalKillPoint.BEFORE_RETIREMENT_FINALIZATION_COMMIT, + compositionCrash.killPoint, + ) + composedStorage.transaction { transaction -> + assertEquals(StoredExecutionPhase.EFFECTS_PENDING, transaction.executions(PRUNING_CLIENT_ID).single().phase) + val effect = transaction.effects(PRUNING_CLIENT_ID).single() + assertEquals(MutationEffectDisposition.PENDING, effect.disposition) + assertEquals(null, effect.completedAt) + assertEquals(0L, requireNotNull(transaction.client(PRUNING_CLIENT_ID)).retiredThroughSequence) + assertEquals(MutationAliasState.PENDING, transaction.aliases().single().state) + } + + val rawStorage = InMemoryMutationJournalStorage() + val killedStorage = EngineKillPointJournalStorage(rawStorage) + val fixture = pruningFixture() + val source = MutationsTestKey("provisional") + val canonical = MutationsTestKey("canonical") + val server = CountingAckServer(canonical) + val adoption = CountingAdoptionHandle() + val engine = openPruningEngine(killedStorage, fixture.registry, server, adoption) + engine.mutate(source, fixture.append, "+mine") + val revisionBefore = engine.aliasRevision(source.identity()).value + killedStorage.arm(EngineJournalKillPoint.BEFORE_RETIREMENT_FINALIZATION_COMMIT) + + val engineCrash = + assertFailsWith { + engine.drain(source) + } + assertEquals( + EngineJournalKillPoint.BEFORE_RETIREMENT_FINALIZATION_COMMIT, + engineCrash.killPoint, + ) + assertEquals(revisionBefore, engine.aliasRevision(source.identity()).value) + rawStorage.transaction { transaction -> + assertEquals(StoredExecutionPhase.EFFECTS_PENDING, transaction.executions(PRUNING_CLIENT_ID).single().phase) + assertTrue(transaction.effects(PRUNING_CLIENT_ID).isEmpty()) + assertEquals(0L, requireNotNull(transaction.client(PRUNING_CLIENT_ID)).retiredThroughSequence) + assertEquals(MutationAliasState.PENDING, transaction.aliases().single().state) + } + } +} + +private enum class EngineJournalKillPoint { + AFTER_ALIAS_INSERT_COMMIT, + BEFORE_RETIREMENT_FINALIZATION_COMMIT, + AFTER_RETIREMENT_FINALIZATION_COMMIT, + BEFORE_PRUNE, + BEFORE_PRUNE_COMMIT, + AFTER_PRUNE_COMMIT, +} + +private class EngineJournalCrashException( + val killPoint: EngineJournalKillPoint, + val aliasCommitIncludedAckAndAcked: Boolean? = null, +) : RuntimeException("Simulated journal crash at $killPoint") + +/** Module-local red-first sibling of the public testing-kit decorator. */ +private class EngineKillPointJournalStorage( + private val delegate: MutationJournalStorage, +) : MutationJournalStorage { + private val gate = Mutex() + private var armed: EngineJournalKillPoint? = null + + suspend fun arm(killPoint: EngineJournalKillPoint) { + gate.withLock { + check(armed == null) { "A journal kill point is already armed: $armed" } + armed = killPoint + } + } + + override suspend fun transaction(block: (MutationJournalTransaction) -> R): R = + gate.withLock { + var committedAliasInsert = false + var aliasCommitIncludedAckAndAcked = false + var committedRetirementFinalization = false + var committedPrune = false + val result = + delegate.transaction { transaction -> + val observed = + EngineObservingJournalTransaction( + delegate = transaction, + beforePrune = { + if (armed == EngineJournalKillPoint.BEFORE_PRUNE) { + crash(EngineJournalKillPoint.BEFORE_PRUNE) + } + }, + ) + val value = block(observed) + if ( + observed.sawRetirementFinalization && + armed == EngineJournalKillPoint.BEFORE_RETIREMENT_FINALIZATION_COMMIT + ) { + crash(EngineJournalKillPoint.BEFORE_RETIREMENT_FINALIZATION_COMMIT) + } + if (observed.sawPrune && armed == EngineJournalKillPoint.BEFORE_PRUNE_COMMIT) { + crash(EngineJournalKillPoint.BEFORE_PRUNE_COMMIT) + } + committedRetirementFinalization = observed.sawRetirementFinalization + committedAliasInsert = observed.sawAliasInsert + aliasCommitIncludedAckAndAcked = + observed.sawAliasInsert && observed.sawAckInsert && observed.sawAckedAdvance + committedPrune = observed.sawPrune + value + } + if ( + committedAliasInsert && + armed == EngineJournalKillPoint.AFTER_ALIAS_INSERT_COMMIT + ) { + crash( + EngineJournalKillPoint.AFTER_ALIAS_INSERT_COMMIT, + aliasCommitIncludedAckAndAcked = aliasCommitIncludedAckAndAcked, + ) + } + if ( + committedRetirementFinalization && + armed == EngineJournalKillPoint.AFTER_RETIREMENT_FINALIZATION_COMMIT + ) { + crash(EngineJournalKillPoint.AFTER_RETIREMENT_FINALIZATION_COMMIT) + } + if (committedPrune && armed == EngineJournalKillPoint.AFTER_PRUNE_COMMIT) { + crash(EngineJournalKillPoint.AFTER_PRUNE_COMMIT) + } + result + } + + private fun crash( + killPoint: EngineJournalKillPoint, + aliasCommitIncludedAckAndAcked: Boolean? = null, + ): Nothing { + check(armed == killPoint) + armed = null + throw EngineJournalCrashException(killPoint, aliasCommitIncludedAckAndAcked) + } +} + +private class EngineObservingJournalTransaction( + private val delegate: MutationJournalTransaction, + private val beforePrune: () -> Unit, +) : MutationJournalTransaction by delegate { + private val entryPhases = mutableMapOf, StoredExecutionPhase>() + + var sawAliasInsert: Boolean = false + private set + var sawAckInsert: Boolean = false + private set + var sawAckedAdvance: Boolean = false + private set + var sawRetirementFinalization: Boolean = false + private set + var sawPrune: Boolean = false + private set + + override fun insertAlias(record: MutationKeyAliasRecord) { + delegate.insertAlias(record) + sawAliasInsert = true + } + + override fun insertAck(record: MutationAckRecord) { + delegate.insertAck(record) + sawAckInsert = true + } + + override fun advanceExecution(record: MutationExecutionRecord) { + val identity = record.clientId to record.clientSequence + val entryPhase = + entryPhases.getOrPut(identity) { + requireNotNull( + delegate.executions(record.clientId).firstOrNull { previous -> + previous.clientSequence == record.clientSequence + }, + ).phase + } + delegate.advanceExecution(record) + if (record.phase == StoredExecutionPhase.ACKED) { + sawAckedAdvance = true + } + if ( + entryPhase == StoredExecutionPhase.EFFECTS_PENDING && + record.phase == StoredExecutionPhase.RETIRED + ) { + sawRetirementFinalization = true + } + } + + override fun prune( + clientId: String, + serverConfirmedRetiredThroughSequence: Long, + ) { + beforePrune() + delegate.prune(clientId, serverConfirmedRetiredThroughSequence) + sawPrune = true + } +} + +private data class PruningFixture( + val registry: MutatorRegistry, + val append: MutatorRef, +) + +private fun pruningFixture(): PruningFixture { + lateinit var append: MutatorRef + val registry = + mutatorRegistry { + append = + upsert( + id = "pruning-append", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { base, suffix -> + val value = (base as? MutationPresence.Present)?.value.orEmpty() + MutationPresence.Present(value + suffix) + } + } + return PruningFixture(registry, append) +} + +private fun openPruningEngine( + storage: MutationJournalStorage, + registry: MutatorRegistry, + server: MutationServer, + adoption: StoreWriteHandle, +): MutationEngine = + MutationEngine( + registry = registry, + server = server, + journal = + StorageBackedMutationJournal( + storage = storage, + registrations = registry.registrations, + clientId = PRUNING_CLIENT_ID, + hydrateOnFirstUse = true, + ), + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + baseReader = { "base" }, + clientId = PRUNING_CLIENT_ID, + ).also { engine -> engine.bind(adoption) } + +private class CountingAckServer( + private val canonicalKey: MutationsTestKey? = null, + private val retirementConfirmationCeiling: Long? = null, +) : MutationServer { + var pushCount: Int = 0 + private set + + override suspend fun push( + request: MutationPush, + ): MutationAck { + pushCount += 1 + return MutationPresentAck( + authoritative = "authoritative-${request.clientSequence}", + etag = "etag-${request.clientSequence}", + canonicalKey = canonicalKey, + ) + } + + override suspend fun retire(request: MutationRetirement): MutationRetirementAck = + MutationRetirementAck( + minOf( + request.retiredThroughSequence, + retirementConfirmationCeiling ?: request.retiredThroughSequence, + ), + ) +} + +private class CountingAdoptionHandle : StoreWriteHandle { + var applyCount: Int = 0 + private set + var confirmFreshCount: Int = 0 + private set + + override suspend fun apply( + key: MutationsTestKey, + value: String, + ) { + applyCount += 1 + } + + override suspend fun markStale(key: MutationsTestKey) = Unit + + override suspend fun confirmFresh( + key: MutationsTestKey, + etag: String?, + ) { + confirmFreshCount += 1 + } +} + +private fun reopenInMemory(storage: MutationJournalStorage): MutationJournalStorage = storage + +private suspend fun assertClientInvariant(storage: MutationJournalStorage) { + val client = requireNotNull(storage.transaction { it.client(PRUNING_CLIENT_ID) }) + assertTrue(client.serverConfirmedRetiredThroughSequence <= client.retiredThroughSequence) + assertTrue(client.retiredThroughSequence <= client.lastAllocatedSequence) +} + +private suspend fun seedEffectsPendingJournal(storage: MutationJournalStorage) { + storage.transaction { transaction -> + transaction.insertClient(pruningClient()) + transaction.advanceClient(pruningClient(lastAllocated = 1L)) + insertPruningIntent(transaction, sequence = 1L) + transaction.insertExecution(pruningExecution(sequence = 1L)) + transaction.insertAttempt(pruningAttempt(sequence = 1L)) + transaction.insertEffect(pruningEffect(sequence = 1L)) + transaction.advanceExecution( + pruningExecution(sequence = 1L, phase = StoredExecutionPhase.READY, generation = 1), + ) + transaction.advanceExecution( + pruningExecution(sequence = 1L, phase = StoredExecutionPhase.INFLIGHT, generation = 1), + ) + transaction.insertAck(pruningAck(sequence = 1L)) + transaction.advanceExecution( + pruningExecution( + sequence = 1L, + phase = StoredExecutionPhase.ACKED, + generation = 1, + attempt = 1, + lastAttemptAt = 40L, + ), + ) + transaction.advanceExecution( + pruningExecution( + sequence = 1L, + phase = StoredExecutionPhase.EFFECTS_PENDING, + generation = 1, + attempt = 1, + lastAttemptAt = 40L, + ), + ) + transaction.insertAlias(pruningAlias()) + } +} + +private suspend fun finalizeSeededRetirement(storage: MutationJournalStorage) { + storage.transaction { transaction -> + transaction.advanceEffect( + pruningEffect( + sequence = 1L, + disposition = MutationEffectDisposition.APPLIED, + completedAt = 50L, + ), + ) + transaction.advanceAlias( + pruningAlias(state = MutationAliasState.ACTIVE, activatedAt = 50L), + ) + transaction.advanceExecution( + pruningExecution( + sequence = 1L, + phase = StoredExecutionPhase.RETIRED, + generation = 1, + attempt = 1, + lastAttemptAt = 40L, + retiredAt = 50L, + ), + ) + transaction.advanceClient(pruningClient(lastAllocated = 1L, retiredThrough = 1L)) + } +} + +private suspend fun seedPrunableJournal(storage: MutationJournalStorage) { + storage.transaction { transaction -> + transaction.insertClient(pruningClient()) + transaction.advanceClient(pruningClient(lastAllocated = 4L)) + (1L..4L).forEach { sequence -> + insertPruningIntent(transaction, sequence) + transaction.insertExecution(pruningExecution(sequence)) + } + } + (1L..4L).forEach { sequence -> + storage.transaction { transaction -> + transaction.insertAttempt(pruningAttempt(sequence)) + transaction.insertEffect(pruningEffect(sequence)) + transaction.advanceExecution( + pruningExecution(sequence, phase = StoredExecutionPhase.READY, generation = 1), + ) + transaction.advanceExecution( + pruningExecution(sequence, phase = StoredExecutionPhase.INFLIGHT, generation = 1), + ) + transaction.insertAck(pruningAck(sequence)) + transaction.appendFailure( + clientId = PRUNING_CLIENT_ID, + clientSequence = sequence, + generation = 1, + kind = MutationFailureKind.EFFECT, + detail = "retained-$sequence", + message = "retained evidence $sequence", + occurredAt = 40L + sequence, + ) + transaction.advanceExecution( + pruningExecution( + sequence, + phase = StoredExecutionPhase.ACKED, + generation = 1, + attempt = 1, + lastAttemptAt = 40L, + ), + ) + transaction.advanceExecution( + pruningExecution( + sequence, + phase = StoredExecutionPhase.EFFECTS_PENDING, + generation = 1, + attempt = 1, + lastAttemptAt = 40L, + ), + ) + transaction.advanceEffect( + pruningEffect( + sequence, + disposition = MutationEffectDisposition.APPLIED, + completedAt = 50L + sequence, + ), + ) + transaction.advanceExecution( + pruningExecution( + sequence, + phase = StoredExecutionPhase.RETIRED, + generation = 1, + attempt = 1, + lastAttemptAt = 40L, + retiredAt = 50L + sequence, + ), + ) + } + } + storage.transaction { transaction -> + transaction.advanceClient(pruningClient(lastAllocated = 4L, retiredThrough = 4L)) + transaction.confirmRetiredThrough( + clientId = PRUNING_CLIENT_ID, + requestedThroughSequence = 2L, + serverConfirmedThroughSequence = 2L, + ) + transaction.insertAlias(pruningAlias(sourceId = "alias-source", targetId = "alias-target", sequence = 3L)) + transaction.advanceAlias( + pruningAlias( + sourceId = "alias-source", + targetId = "alias-target", + sequence = 3L, + state = MutationAliasState.ACTIVE, + activatedAt = 70L, + ), + ) + transaction.insertTombstone(pruningTombstone(sequence = 3L, canonicalId = "protected-active")) + transaction.advanceTombstone( + pruningTombstone( + sequence = 3L, + canonicalId = "protected-active", + state = MutationTombstoneState.ACTIVE, + activatedAt = 70L, + ), + ) + transaction.insertTombstone(pruningTombstone(sequence = 1L, canonicalId = "eligible-superseded")) + transaction.advanceTombstone( + pruningTombstone( + sequence = 1L, + canonicalId = "eligible-superseded", + state = MutationTombstoneState.ACTIVE, + activatedAt = 60L, + ), + ) + transaction.advanceTombstone( + pruningTombstone( + sequence = 1L, + canonicalId = "eligible-superseded", + state = MutationTombstoneState.SUPERSEDED, + activatedAt = 60L, + supersededBySequence = 2L, + supersededAt = 65L, + ), + ) + } +} + +private data class ProtectedJournalSnapshot( + val rows: List, +) + +private fun protectedJournalSnapshot(transaction: MutationJournalTransaction): ProtectedJournalSnapshot { + val client = requireNotNull(transaction.client(PRUNING_CLIENT_ID)) + val rows = mutableListOf() + rows += + "client:${client.recordVersion}:${client.clientId}:${client.lastAllocatedSequence}:" + + "${client.retiredThroughSequence}:${client.serverConfirmedRetiredThroughSequence}:${client.createdAt}" + transaction.intents(PRUNING_CLIENT_ID).filter { it.clientSequence > 2L }.forEach { row -> + rows += + "intent:${row.rowId}:${row.recordVersion}:${row.clientId}:${row.clientSequence}:" + + "${row.mutationId}:${row.namespace}:${row.canonicalId}:${row.mutatorId}:" + + "${row.mutatorVersion}:${row.argsBlob.toList()}:${row.idempotencyRoot}:${row.createdAt}" + } + transaction.executions(PRUNING_CLIENT_ID).filter { it.clientSequence > 2L }.forEach { row -> + rows += + "execution:${row.clientId}:${row.clientSequence}:${row.phase}:${row.currentGeneration}:" + + "${row.attempt}:${row.lastAttemptAt}:${row.activeFailureId}:${row.retiredAt}" + } + transaction.attempts(PRUNING_CLIENT_ID).filter { it.clientSequence > 2L }.forEach { row -> + rows += + "attempt:${row.clientId}:${row.clientSequence}:${row.generation}:${row.effectiveNamespace}:" + + "${row.effectiveCanonicalId}:${row.valueCodecVersion}:${row.basePresence}:${row.baseBlob?.toList()}:" + + "${row.minePresence}:${row.mineBlob?.toList()}:${row.preconditionMetaPresent}:" + + "${row.preconditionWrittenAt}:${row.preconditionEtag}:${row.advertisedRetiredThroughSequence}:" + + "${row.generationIdempotencyKey}:${row.preparedAt}:${row.conflictMetaPresent}:" + + "${row.conflictWrittenAt}:${row.conflictEtag}:${row.conflictReceivedAt}" + } + transaction.acks(PRUNING_CLIENT_ID).filter { it.clientSequence > 2L }.forEach { row -> + rows += + "ack:${row.clientId}:${row.clientSequence}:${row.generation}:${row.authoritativePresence}:" + + "${row.authoritativeBlob?.toList()}:${row.valueCodecVersion}:${row.etag}:" + + "${row.canonicalTargetNamespace}:${row.canonicalTargetId}:${row.receivedAt}" + } + transaction.failures(PRUNING_CLIENT_ID).filter { it.clientSequence > 2L }.forEach { row -> + rows += + "failure:${row.failureId}:${row.clientId}:${row.clientSequence}:${row.generation}:" + + "${row.kind}:${row.detail}:${row.message}:${row.occurredAt}" + } + transaction.effects(PRUNING_CLIENT_ID).filter { it.clientSequence > 2L }.forEach { row -> + rows += + "effect:${row.clientId}:${row.clientSequence}:${row.effectIndex}:${row.kind}:" + + "${row.namespace}:${row.canonicalId}:${row.createdAt}:${row.disposition}:${row.completedAt}" + } + transaction.aliases().forEach { row -> + rows += + "alias:${row.sourceNamespace}:${row.sourceCanonicalId}:${row.targetNamespace}:" + + "${row.targetCanonicalId}:${row.state}:${row.createdByClientId}:${row.createdBySequence}:" + + "${row.createdAt}:${row.activatedAt}" + } + transaction.tombstones().filter { it.state != MutationTombstoneState.SUPERSEDED }.forEach { row -> + rows += + "tombstone:${row.namespace}:${row.canonicalId}:${row.createdByClientId}:" + + "${row.createdBySequence}:${row.state}:${row.createdAt}:${row.activatedAt}:" + + "${row.supersededByClientId}:${row.supersededBySequence}:${row.supersededAt}" + } + return ProtectedJournalSnapshot(rows) +} + +private fun insertPruningIntent( + transaction: MutationJournalTransaction, + sequence: Long, +) { + transaction.insertIntent( + recordVersion = 1, + clientId = PRUNING_CLIENT_ID, + clientSequence = sequence, + mutationId = "seed-mutation-$sequence", + namespace = "mutations", + canonicalId = "item-$sequence", + mutatorId = "pruning-append", + mutatorVersion = 1, + argsBlob = byteArrayOf(sequence.toByte()), + idempotencyRoot = "root-$sequence", + createdAt = 10L + sequence, + ) +} + +private fun pruningClient( + lastAllocated: Long = 0L, + retiredThrough: Long = 0L, +): MutationClientRecord = + MutationClientRecord( + recordVersion = 1, + clientId = PRUNING_CLIENT_ID, + lastAllocatedSequence = lastAllocated, + retiredThroughSequence = retiredThrough, + serverConfirmedRetiredThroughSequence = 0L, + createdAt = 1L, + ) + +private fun pruningExecution( + sequence: Long, + phase: StoredExecutionPhase = StoredExecutionPhase.UNPREPARED, + generation: Int = 0, + attempt: Int = 0, + lastAttemptAt: Long? = null, + retiredAt: Long? = null, +): MutationExecutionRecord = + MutationExecutionRecord( + clientId = PRUNING_CLIENT_ID, + clientSequence = sequence, + phase = phase, + currentGeneration = generation, + attempt = attempt, + lastAttemptAt = lastAttemptAt, + activeFailureId = null, + retiredAt = retiredAt, + ) + +private fun pruningAttempt(sequence: Long): MutationAttemptRecord = + MutationAttemptRecord( + clientId = PRUNING_CLIENT_ID, + clientSequence = sequence, + generation = 1, + effectiveNamespace = "mutations", + effectiveCanonicalId = "item-$sequence", + valueCodecVersion = 1, + basePresence = MutationPresenceState.PRESENT, + baseBlob = byteArrayOf(1), + minePresence = MutationPresenceState.PRESENT, + mineBlob = byteArrayOf(sequence.toByte()), + preconditionMetaPresent = false, + preconditionWrittenAt = null, + preconditionEtag = null, + advertisedRetiredThroughSequence = 0L, + generationIdempotencyKey = "generation-$sequence-1", + preparedAt = 20L + sequence, + conflictMetaPresent = null, + conflictWrittenAt = null, + conflictEtag = null, + conflictReceivedAt = null, + ) + +private fun pruningAck(sequence: Long): MutationAckRecord = + MutationAckRecord( + clientId = PRUNING_CLIENT_ID, + clientSequence = sequence, + generation = 1, + authoritativePresence = MutationPresenceState.PRESENT, + authoritativeBlob = byteArrayOf(sequence.toByte()), + valueCodecVersion = 1, + etag = "etag-$sequence", + canonicalTargetNamespace = null, + canonicalTargetId = null, + receivedAt = 40L + sequence, + ) + +private fun pruningEffect( + sequence: Long, + disposition: MutationEffectDisposition = MutationEffectDisposition.PENDING, + completedAt: Long? = null, +): MutationEffectRecord = + MutationEffectRecord( + clientId = PRUNING_CLIENT_ID, + clientSequence = sequence, + effectIndex = 0, + kind = MutationEffectKind.KEY, + namespace = "mutations", + canonicalId = "effect-$sequence", + createdAt = 30L + sequence, + disposition = disposition, + completedAt = completedAt, + ) + +private fun pruningAlias( + sourceId: String = "item-1", + targetId: String = "canonical-1", + sequence: Long = 1L, + state: MutationAliasState = MutationAliasState.PENDING, + activatedAt: Long? = null, +): MutationKeyAliasRecord = + MutationKeyAliasRecord( + sourceNamespace = "mutations", + sourceCanonicalId = sourceId, + targetNamespace = "mutations", + targetCanonicalId = targetId, + state = state, + createdByClientId = PRUNING_CLIENT_ID, + createdBySequence = sequence, + createdAt = 10L + sequence, + activatedAt = activatedAt, + ) + +private fun pruningTombstone( + sequence: Long, + canonicalId: String, + state: MutationTombstoneState = MutationTombstoneState.PENDING, + activatedAt: Long? = null, + supersededBySequence: Long? = null, + supersededAt: Long? = null, +): MutationKeyTombstoneRecord = + MutationKeyTombstoneRecord( + namespace = "mutations", + canonicalId = canonicalId, + createdByClientId = PRUNING_CLIENT_ID, + createdBySequence = sequence, + state = state, + createdAt = 10L + sequence, + activatedAt = activatedAt, + supersededByClientId = supersededBySequence?.let { PRUNING_CLIENT_ID }, + supersededBySequence = supersededBySequence, + supersededAt = supersededAt, + ) + +private const val PRUNING_CLIENT_ID: String = "pruning-client" diff --git a/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationRebaseTest.kt b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationRebaseTest.kt new file mode 100644 index 000000000..d2757f422 --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationRebaseTest.kt @@ -0,0 +1,267 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.mutations + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.mutations.storage.InMemoryMutationJournalStorage +import org.mobilenativefoundation.store6.testing.FakeSourceOfTruth +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +class MutationRebaseTest { + @Test + fun restartReprojectsPendingFifoOverRefreshedBaseAndNeverAdoptsOldMine() = + runTest(timeout = 25.seconds) { + val storage = InMemoryMutationJournalStorage() + val sourceOfTruth = FakeSourceOfTruth() + val key = MutationsTestKey("rebase") + lateinit var append: MutatorRef + val registry = + mutatorRegistry { + append = + upsert( + id = "append", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { base, suffix -> + val current = (base as? MutationPresence.Present)?.value.orEmpty() + MutationPresence.Present(current + suffix) + } + } + + sourceOfTruth.write(key, "old") + val firstServer = RebaseServer(online = false) + openStore(storage, sourceOfTruth, registry, firstServer).use { first -> + first.mutate(key, append, "+one") + first.drain(key) + first.mutate(key, append, "+two") + } + val originalFailedPush = firstServer.pushes.single().snapshot() + val phasesAfterFailure = + storage.transaction { transaction -> + transaction.executions("client-0").map { execution -> execution.phase } + } + assertEquals( + listOf( + org.mobilenativefoundation.store6.mutations.storage.MutationExecutionPhase.READY, + org.mobilenativefoundation.store6.mutations.storage.MutationExecutionPhase.UNPREPARED, + ), + phasesAfterFailure, + ) + val immutableRetry = storage.transaction { it.attempts("client-0").single() } + sourceOfTruth.write(key, "refreshed") + + val resumedServer = + RebaseServer( + online = true, + firstAuthoritative = "authoritative", + retirementConfirmationCeiling = 0L, + ) + openStore(storage, sourceOfTruth, registry, resumedServer).use { reopened -> + val optimistic = + reopened.stream(key, Freshness.LocalOnly).first { result -> + result is StoreResult.Data && + result.origin == Origin.OVERLAY && + result.value == "refreshed+one+two" + } + assertEquals("refreshed+one+two", assertIs>(optimistic).value) + assertEquals(0, resumedServer.pushes.size) + + reopened.drain(key) + + assertEquals(originalFailedPush, resumedServer.pushes.first().snapshot()) + assertEquals( + listOf("old", "authoritative"), + resumedServer.pushes.map { push -> + (push.base as MutationPresence.Present).value + }, + ) + assertEquals( + listOf("old+one", "authoritative+two"), + resumedServer.pushes.map { push -> + (push.mine as MutationPresence.Present).value + }, + ) + assertEquals("authoritative+two", reopened.get(key, Freshness.LocalOnly)) + assertEquals(emptyList(), reopened.pending(key)) + } + + val attempts = storage.transaction { it.attempts("client-0") } + assertEquals(2, attempts.size) + assertContentEquals("old".encodeToByteArray(), attempts[0].baseBlob) + assertContentEquals("old+one".encodeToByteArray(), attempts[0].mineBlob) + assertEquals(immutableRetry.generation, attempts[0].generation) + assertEquals(immutableRetry.generationIdempotencyKey, attempts[0].generationIdempotencyKey) + assertContentEquals("authoritative".encodeToByteArray(), attempts[1].baseBlob) + assertContentEquals("authoritative+two".encodeToByteArray(), attempts[1].mineBlob) + } + + @Test + fun restartCapturesAllUnpreparedIntentsInFifoOrderOverRefreshedBase() = + runTest(timeout = 25.seconds) { + val storage = InMemoryMutationJournalStorage() + val sourceOfTruth = FakeSourceOfTruth() + val key = MutationsTestKey("unprepared-rebase") + lateinit var append: MutatorRef + val registry = + mutatorRegistry { + append = + upsert( + id = "append-unprepared", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { base, suffix -> + val current = (base as? MutationPresence.Present)?.value.orEmpty() + MutationPresence.Present(current + suffix) + } + } + + sourceOfTruth.write(key, "old") + openStore(storage, sourceOfTruth, registry, RebaseServer(online = false)).use { first -> + first.mutate(key, append, "+one") + first.mutate(key, append, "+two") + } + assertTrue(storage.transaction { it.attempts("client-0").isEmpty() }) + sourceOfTruth.write(key, "refreshed") + + val resumedServer = RebaseServer(online = true, retirementConfirmationCeiling = 0L) + openStore(storage, sourceOfTruth, registry, resumedServer).use { reopened -> + val optimistic = + reopened.stream(key, Freshness.LocalOnly).first { result -> + result is StoreResult.Data && + result.origin == Origin.OVERLAY && + result.value == "refreshed+one+two" + } + assertEquals("refreshed+one+two", assertIs>(optimistic).value) + assertEquals(0, resumedServer.pushes.size) + + reopened.drain(key) + + assertEquals( + listOf("refreshed", "refreshed+one"), + resumedServer.pushes.map { push -> + (push.base as MutationPresence.Present).value + }, + ) + assertEquals( + listOf("refreshed+one", "refreshed+one+two"), + resumedServer.pushes.map { push -> + (push.mine as MutationPresence.Present).value + }, + ) + assertEquals("refreshed+one+two", reopened.get(key, Freshness.LocalOnly)) + assertEquals(emptyList(), reopened.pending(key)) + } + + val attempts = storage.transaction { it.attempts("client-0") } + assertEquals(2, attempts.size) + assertContentEquals("refreshed".encodeToByteArray(), attempts[0].baseBlob) + assertContentEquals("refreshed+one".encodeToByteArray(), attempts[0].mineBlob) + assertContentEquals("refreshed+one".encodeToByteArray(), attempts[1].baseBlob) + assertContentEquals("refreshed+one+two".encodeToByteArray(), attempts[1].mineBlob) + } + + private fun openStore( + storage: InMemoryMutationJournalStorage, + sourceOfTruth: FakeSourceOfTruth, + registry: MutatorRegistry, + server: MutationServer, + ): MutationStore = + mutationStore( + registry = registry, + server = server, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + ) { + fetcher { error("LocalOnly replay must not fetch") } + persistence(sourceOfTruth) + journalStorage(storage) + } +} + +private class RebaseServer( + private val online: Boolean, + private val firstAuthoritative: String? = null, + private val retirementConfirmationCeiling: Long? = null, +) : MutationServer { + val pushes = mutableListOf>() + + override suspend fun push( + request: MutationPush, + ): MutationAck { + pushes += request + check(online) { "server is offline" } + return when (val mine = request.mine) { + is MutationPresence.Present -> + MutationPresentAck( + authoritative = + if (request.clientSequence == 1L) { + firstAuthoritative ?: mine.value + } else { + mine.value + }, + etag = "etag-${request.clientSequence}", + canonicalKey = null, + ) + MutationPresence.Absent -> MutationAbsentAck(etag = null) + } + } + + override suspend fun retire(request: MutationRetirement): MutationRetirementAck = + MutationRetirementAck( + minOf( + request.retiredThroughSequence, + retirementConfirmationCeiling ?: request.retiredThroughSequence, + ), + ) +} + +private data class RebasePushSnapshot( + val identity: Pair, + val clientId: String, + val sequence: Long, + val retiredPrefix: Long, + val mutationId: String, + val generation: Int, + val idempotencyKey: String, + val codecVersion: Int, + val base: String?, + val mine: String?, + val metaWrittenAt: Long?, + val metaEtag: String?, +) + +private fun MutationPush.snapshot(): RebasePushSnapshot = + RebasePushSnapshot( + identity = identity.namespace to identity.canonicalId, + clientId = clientId, + sequence = clientSequence, + retiredPrefix = retiredThroughSequence, + mutationId = mutationId, + generation = generation, + idempotencyKey = idempotencyKey, + codecVersion = valueCodecVersion, + base = (base as? MutationPresence.Present)?.value, + mine = (mine as? MutationPresence.Present)?.value, + metaWrittenAt = baseMeta?.writtenAtEpochMillis, + metaEtag = baseMeta?.etag, + ) + +private inline fun + MutationStore.use(block: (MutationStore) -> R): R = + try { + block(this) + } finally { + close() + } diff --git a/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationRestartWalkingTest.kt b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationRestartWalkingTest.kt new file mode 100644 index 000000000..ae50734f5 --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationRestartWalkingTest.kt @@ -0,0 +1,144 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.mutations + +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.mutations.storage.InMemoryMutationJournalStorage +import org.mobilenativefoundation.store6.testing.FakeSourceOfTruth +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +class MutationRestartWalkingTest { + @Test + fun offlineEnqueue_restartShowsOverlayBeforeConnectivity_thenConfirms() = + runTest(timeout = 25.seconds) { + val storage = InMemoryMutationJournalStorage() + val sourceOfTruth = FakeSourceOfTruth() + val key = MutationsTestKey("restart-walking") + val server = RestartWalkingServer() + lateinit var append: MutatorRef + val registry = + mutatorRegistry { + append = + upsert( + id = "append", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { base, suffix -> + val value = (base as? MutationPresence.Present)?.value.orEmpty() + MutationPresence.Present(value + suffix) + } + } + + sourceOfTruth.write(key, "base") + openRestartWalkingStore(storage, sourceOfTruth, registry, server).use { first -> + first.mutate(key, append, "+mine") + first.drain(key) + assertEquals(listOf("push:1"), server.calls) + assertEquals(listOf("mutation-1"), first.pending(key).map(PendingIntent::mutationId)) + } + val callsBeforeRestart = server.calls.toList() + + openRestartWalkingStore(storage, sourceOfTruth, registry, server).use { reopened -> + val emissions = Channel>(Channel.UNLIMITED) + val collection = + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + reopened.stream(key, Freshness.LocalOnly).collect(emissions::send) + } + try { + val optimistic = + emissions.awaitData { data -> + data.value == "base+mine" && data.origin == Origin.OVERLAY + } + assertEquals("base+mine", optimistic.value) + assertTrue(!server.online) + assertEquals(callsBeforeRestart, server.calls) + + server.online = true + reopened.drain(key) + + val confirmed = + emissions.awaitData { data -> + data.value == "confirmed:base+mine" && data.origin != Origin.OVERLAY + } + assertEquals("confirmed:base+mine", confirmed.value) + assertEquals(2, server.calls.count { call -> call == "push:1" }) + assertEquals(emptyList(), reopened.pending(key)) + } finally { + collection.cancelAndJoin() + emissions.cancel() + } + } + } +} + +private fun openRestartWalkingStore( + storage: InMemoryMutationJournalStorage, + sourceOfTruth: FakeSourceOfTruth, + registry: MutatorRegistry, + server: MutationServer, +): MutationStore = + mutationStore( + registry = registry, + server = server, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + ) { + fetcher { error("The AC-4 LocalOnly scenario must not fetch") } + persistence(sourceOfTruth) + journalStorage(storage) + } + +private class RestartWalkingServer : MutationServer { + var online: Boolean = false + val calls: MutableList = mutableListOf() + + override suspend fun push( + request: MutationPush, + ): MutationAck { + calls += "push:${request.clientSequence}" + check(online) { "server is offline" } + val mine = (request.mine as MutationPresence.Present).value + return MutationPresentAck( + authoritative = "confirmed:$mine", + etag = "etag-${request.clientSequence}", + canonicalKey = null, + ) + } + + override suspend fun retire(request: MutationRetirement): MutationRetirementAck { + calls += "retire:${request.retiredThroughSequence}" + check(online) { "server is offline" } + return MutationRetirementAck(request.retiredThroughSequence) + } +} + +private suspend fun ReceiveChannel>.awaitData( + predicate: (StoreResult.Data) -> Boolean, +): StoreResult.Data { + while (true) { + val result = receive() + if (result is StoreResult.Data && predicate(result)) return result + } +} + +private inline fun + MutationStore.use(block: (MutationStore) -> R): R = + try { + block(this) + } finally { + close() + } diff --git a/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationRetirementTest.kt b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationRetirementTest.kt new file mode 100644 index 000000000..edaab32b1 --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationRetirementTest.kt @@ -0,0 +1,1702 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, +) + +package org.mobilenativefoundation.store6.mutations + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.seam.StoreWriteHandle +import org.mobilenativefoundation.store6.mutations.storage.InMemoryMutationJournalStorage +import org.mobilenativefoundation.store6.mutations.storage.MutationAckRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationAliasState +import org.mobilenativefoundation.store6.mutations.storage.MutationAttemptRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationClientRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionPhase as StoredPhase +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationIntentRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationJournalStorage +import org.mobilenativefoundation.store6.mutations.storage.MutationJournalTransaction +import org.mobilenativefoundation.store6.mutations.storage.MutationKeyAliasRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationKeyTombstoneRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationTombstoneState +import org.mobilenativefoundation.store6.testing.TestWallClock +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +class MutationRetirementTest { + @Test + fun consecutiveDeletes_keepOldActiveUntilNewDeleteRetires() = runTest { + val storage = FailPointJournalStorage(InMemoryMutationJournalStorage()) + val mutations = RetirementMutations() + val backend = retainingRetirementBackend() + val handle = RecordingRetirementHandle() + val clock = TestWallClock(1_000L) + val key = MutationsTestKey("consecutive-delete") + val engine = openRetirementEngine(storage, mutations, backend, handle, clock) + + val firstDelete = engine.mutate(key, mutations.deleteRef, Unit) + engine.drain(key) + val firstRetired = storage.retirementState() + val firstSequence = firstRetired.sequenceOf(firstDelete) + val firstActive = firstRetired.tombstoneCreatedBy(firstSequence) + assertActiveTombstone(firstActive, firstRetired.executionOf(firstDelete).retiredAt) + + clock.setEpochMillis(2_000L) + val secondDelete = engine.mutate(key, mutations.deleteRef, Unit) + storage.armFailTransaction(JournalFailPointBoundary.ADOPTION_ADVANCE) + assertIs(captureRetirementFailure { engine.drain(key) }) + + val held = storage.retirementState() + val secondSequence = held.sequenceOf(secondDelete) + assertEquals(StoredPhase.ACKED, held.executionOf(secondDelete).phase) + assertActiveTombstone(held.tombstoneCreatedBy(firstSequence), firstActive.activatedAt) + assertPendingTombstone(held.tombstoneCreatedBy(secondSequence)) + + val errors = mutableListOf() + val finalizationFailure = captureRetirementFailure { engine.drain(key) } + expectRetirement(errors, "the second delete finalizes") { + assertNull(finalizationFailure) + } + val completed = storage.retirementState() + expectRetirement(errors, "the first generation is superseded by the second delete") { + val retiredAt = assertNotNull(completed.executionOf(secondDelete).retiredAt) + assertSupersededTombstone( + record = completed.tombstoneCreatedBy(firstSequence), + successorSequence = secondSequence, + activatedAt = assertNotNull(firstActive.activatedAt), + supersededAt = retiredAt, + ) + } + expectRetirement(errors, "the second delete generation becomes active") { + val retiredAt = completed.executionOf(secondDelete).retiredAt + assertActiveTombstone(completed.tombstoneCreatedBy(secondSequence), retiredAt) + } + assertTrue(errors.isEmpty(), errors.joinToString(separator = "\n")) + } + + @Test + fun newDeleteRetirement_atomicallySupersedesOldAndActivatesNew() = runTest { + val storage = FailPointJournalStorage(InMemoryMutationJournalStorage()) + val mutations = RetirementMutations() + val backend = retainingRetirementBackend() + val handle = RecordingRetirementHandle() + val clock = TestWallClock(3_000L) + val key = MutationsTestKey("atomic-delete-retirement") + val engine = openRetirementEngine(storage, mutations, backend, handle, clock) + + val firstDelete = engine.mutate(key, mutations.deleteRef, Unit) + engine.drain(key) + clock.setEpochMillis(4_000L) + val secondDelete = engine.mutate(key, mutations.deleteRef, Unit) + storage.armKillBeforeCommit(JournalFailPointBoundary.FINALIZATION) + + val errors = mutableListOf() + val interruptedFailure = captureRetirementFailure { engine.drain(key) } + expectRetirement(errors, "the finalization fail point kills before commit") { + val death = assertIs(interruptedFailure) + assertEquals(false, death.committed) + assertEquals(listOf(JournalFailPointBoundary.FINALIZATION), storage.triggeredBoundaries) + } + + val interrupted = storage.retirementState() + val firstSequence = interrupted.sequenceOf(firstDelete) + val secondSequence = interrupted.sequenceOf(secondDelete) + expectRetirement(errors, "no half-superseded tombstone state survives restart") { + assertEquals(StoredPhase.EFFECTS_PENDING, interrupted.executionOf(secondDelete).phase) + assertActiveTombstone( + interrupted.tombstoneCreatedBy(firstSequence), + interrupted.executionOf(firstDelete).retiredAt, + ) + assertPendingTombstone(interrupted.tombstoneCreatedBy(secondSequence)) + } + + val reopened = openRetirementEngine(storage, mutations, backend, handle, clock) + val cleanFailure = captureRetirementFailure { reopened.drain(key) } + expectRetirement(errors, "a clean restart completes finalization") { + assertNull(cleanFailure) + } + val completed = storage.retirementState() + expectRetirement(errors, "one committed transaction links and activates both generations") { + val retiredAt = assertNotNull(completed.executionOf(secondDelete).retiredAt) + assertSupersededTombstone( + record = completed.tombstoneCreatedBy(firstSequence), + successorSequence = secondSequence, + activatedAt = assertNotNull(interrupted.tombstoneCreatedBy(firstSequence).activatedAt), + supersededAt = retiredAt, + ) + assertActiveTombstone(completed.tombstoneCreatedBy(secondSequence), retiredAt) + } + assertTrue(errors.isEmpty(), errors.joinToString(separator = "\n")) + } + + @Test + fun restartBetweenAbsentAckAndRetirement_preservesOldActiveAndNewPending() = runTest { + val storage = FailPointJournalStorage(InMemoryMutationJournalStorage()) + val mutations = RetirementMutations() + val backend = retainingRetirementBackend() + val handle = RecordingRetirementHandle() + val clock = TestWallClock(5_000L) + val key = MutationsTestKey("restart-after-absent-ack") + val first = openRetirementEngine(storage, mutations, backend, handle, clock) + + val firstDelete = first.mutate(key, mutations.deleteRef, Unit) + first.drain(key) + clock.setEpochMillis(6_000L) + val secondDelete = first.mutate(key, mutations.deleteRef, Unit) + storage.armKillAfterCommit(JournalFailPointBoundary.ACK_RECEIPT) + + val death = + assertIs( + captureRetirementFailure { first.drain(key) }, + ) + assertTrue(death.committed) + assertEquals(listOf(JournalFailPointBoundary.ACK_RECEIPT), storage.triggeredBoundaries) + + val interrupted = storage.retirementState() + val firstSequence = interrupted.sequenceOf(firstDelete) + val secondSequence = interrupted.sequenceOf(secondDelete) + assertEquals(StoredPhase.ACKED, interrupted.executionOf(secondDelete).phase) + assertActiveTombstone( + interrupted.tombstoneCreatedBy(firstSequence), + interrupted.executionOf(firstDelete).retiredAt, + ) + assertPendingTombstone(interrupted.tombstoneCreatedBy(secondSequence)) + + val errors = mutableListOf() + val reopened = openRetirementEngine(storage, mutations, backend, handle, clock) + val cleanFailure = captureRetirementFailure { reopened.drain(key) } + expectRetirement(errors, "the restarted drain completes without another push") { + assertNull(cleanFailure) + assertEquals(2, backend.receivedPushes.size) + } + val completed = storage.retirementState() + expectRetirement(errors, "restart supersedes the old generation and activates the pending one") { + val retiredAt = assertNotNull(completed.executionOf(secondDelete).retiredAt) + assertSupersededTombstone( + record = completed.tombstoneCreatedBy(firstSequence), + successorSequence = secondSequence, + activatedAt = assertNotNull(interrupted.tombstoneCreatedBy(firstSequence).activatedAt), + supersededAt = retiredAt, + ) + assertActiveTombstone(completed.tombstoneCreatedBy(secondSequence), retiredAt) + } + assertTrue(errors.isEmpty(), errors.joinToString(separator = "\n")) + } + + @Test + fun presentThenDelete_createsNewGeneration() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = RetirementMutations() + val backend = retainingRetirementBackend() + val handle = RecordingRetirementHandle() + val clock = TestWallClock(7_000L) + val key = MutationsTestKey("delete-present-delete") + val engine = openRetirementEngine(storage, mutations, backend, handle, clock) + + val firstDelete = engine.mutate(key, mutations.deleteRef, Unit) + engine.drain(key) + clock.setEpochMillis(8_000L) + val present = engine.mutate(key, mutations.set, "restored") + engine.drain(key) + + val errors = mutableListOf() + val afterPresent = storage.retirementState() + val firstSequence = afterPresent.sequenceOf(firstDelete) + val presentSequence = afterPresent.sequenceOf(present) + expectRetirement(errors, "the Present retirement supersedes the active delete generation") { + assertSupersededTombstone( + record = afterPresent.tombstoneCreatedBy(firstSequence), + successorSequence = presentSequence, + activatedAt = 7_000L, + supersededAt = assertNotNull(afterPresent.executionOf(present).retiredAt), + ) + } + + clock.setEpochMillis(9_000L) + val secondDelete = engine.mutate(key, mutations.deleteRef, Unit) + val secondDeleteFailure = captureRetirementFailure { engine.drain(key) } + expectRetirement(errors, "the post-Present delete retires cleanly") { + assertNull(secondDeleteFailure) + } + + val completed = storage.retirementState() + val secondDeleteSequence = completed.sequenceOf(secondDelete) + expectRetirement(errors, "delete-present-delete retains two distinct tombstone generations") { + assertNotEquals(firstSequence, secondDeleteSequence) + assertEquals( + setOf(firstSequence, secondDeleteSequence), + completed.tombstones.map { it.createdBySequence }.toSet(), + ) + assertEquals( + emptyList(), + completed.tombstones.filter { it.createdBySequence == presentSequence }, + ) + } + expectRetirement(errors, "the first generation keeps its Present successor linkage") { + assertSupersededTombstone( + record = completed.tombstoneCreatedBy(firstSequence), + successorSequence = presentSequence, + activatedAt = 7_000L, + supersededAt = assertNotNull(completed.executionOf(present).retiredAt), + ) + } + expectRetirement(errors, "the later delete owns the new active generation") { + val retiredAt = completed.executionOf(secondDelete).retiredAt + assertActiveTombstone(completed.tombstoneCreatedBy(secondDeleteSequence), retiredAt) + assertEquals(3L, assertNotNull(completed.client).retiredThroughSequence) + } + assertTrue(errors.isEmpty(), errors.joinToString(separator = "\n")) + } + + @Test + fun parkedSequence_pinsRetiredPrefix_butNotSameKeyExecution() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = RetirementMutations() + val backend = FakeBackend() + val handle = RecordingRetirementHandle() + val clock = TestWallClock(10_000L) + val key = MutationsTestKey("retirement-gap") + val resolver = + MutationKeyResolver { + throw IllegalStateException("park the first sequence") + } + val engine = openRetirementEngine(storage, mutations, backend, handle, clock, resolver) + + val parked = engine.mutate(key, mutations.append, "+parked") + val delete = engine.mutate(key, mutations.deleteRef, Unit) + val present = engine.mutate(key, mutations.set, "restored") + engine.clearLiveKeyCache() + engine.drain() + engine.drain(key) + + val beforeRestart = storage.retirementState() + assertEquals(StoredPhase.PARKED, beforeRestart.executionOf(parked).phase) + assertEquals(StoredPhase.RETIRED, beforeRestart.executionOf(delete).phase) + assertEquals(StoredPhase.RETIRED, beforeRestart.executionOf(present).phase) + assertEquals(0L, assertNotNull(beforeRestart.client).retiredThroughSequence) + assertTrue(beforeRestart.executionOf(delete).retiredAt != null) + assertTrue(beforeRestart.executionOf(present).retiredAt != null) + assertEquals(listOf(0L, 0L), backend.receivedPushes.map { it.retiredThroughSequence }) + assertEquals(listOf("restored"), backend.pushedValues) + + val reopened = openRetirementEngine(storage, mutations, backend, handle, clock) + assertEquals(listOf(parked), reopened.deadLetters().map { it.mutationId }) + assertEquals(emptyList(), reopened.pendingWrites()) + val afterRestart = storage.retirementState() + assertEquals( + beforeRestart.accountingFor(parked, delete, present), + afterRestart.accountingFor(parked, delete, present), + ) + } + + @Test + fun keylessDrain_confirmsCurrentPrefixAfterFinalLocalRetirement() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = RetirementMutations() + val backend = FakeBackend() + val handle = RecordingRetirementHandle() + val clock = TestWallClock(11_000L) + val key = MutationsTestKey("checkpoint-keyless") + backend.retireBehavior = { request -> + MutationRetirementAck( + confirmedThroughSequence = + if (backend.retirementRequests.size == 1) { + 0L + } else { + request.retiredThroughSequence + }, + ) + } + val engine = openRetirementEngine(storage, mutations, backend, handle, clock) + + engine.mutate(key, mutations.set, "retired") + engine.drain(key) + + val afterLocal = assertNotNull(storage.transaction { it.client(RETIREMENT_CLIENT_ID) }) + assertEquals(1L, afterLocal.retiredThroughSequence) + assertEquals(0L, afterLocal.serverConfirmedRetiredThroughSequence) + assertEquals(listOf(1L), backend.retirementRequests.map { it.retiredThroughSequence }) + + engine.drain() + + val afterKeyless = assertNotNull(storage.transaction { it.client(RETIREMENT_CLIENT_ID) }) + assertEquals(1L, afterKeyless.retiredThroughSequence) + assertEquals(1L, afterKeyless.serverConfirmedRetiredThroughSequence) + assertEquals(listOf(1L, 1L), backend.retirementRequests.map { it.retiredThroughSequence }) + assertTrue(storage.transaction { it.intents(RETIREMENT_CLIENT_ID).isEmpty() }) + assertTrue(storage.transaction { it.executions(RETIREMENT_CLIENT_ID).isEmpty() }) + + val reopened = openRetirementEngine(storage, mutations, backend, handle, clock) + reopened.ensureHydrated() + val afterRestart = assertNotNull(storage.transaction { it.client(RETIREMENT_CLIENT_ID) }) + assertEquals(1L, afterRestart.serverConfirmedRetiredThroughSequence) + assertTrue(reopened.pendingWrites().isEmpty()) + } + + @Test + fun localHighWaterStopsAtEveryUnretiredSequenceGap() = runTest { + val gaps = + listOf( + CheckpointGap.PARKED, + CheckpointGap.ACTIVE, + CheckpointGap.EFFECTS_PENDING, + ) + + gaps.forEach { gap -> + val storage = InMemoryMutationJournalStorage() + seedCheckpointGap(storage, gap) + val mutations = RetirementMutations() + val backend = FakeBackend() + backend.retireBehavior = { MutationRetirementAck(confirmedThroughSequence = 0L) } + val engine = + openRetirementEngine( + storage = storage, + mutations = mutations, + backend = backend, + handle = RecordingRetirementHandle(), + clock = TestWallClock(12_000L), + resolver = RETIREMENT_ANY_NAMESPACE_RESOLVER, + ) + + engine.ensureHydrated() + engine.drain( + MutationsTestKey( + id = "checkpoint-probe-${gap.name.lowercase()}", + namespace = StoreNamespace("checkpoint-probe"), + ), + ) + + val client = assertNotNull(storage.transaction { it.client(RETIREMENT_CLIENT_ID) }) + assertEquals(1L, client.retiredThroughSequence, gap.name) + assertEquals( + listOf(1L), + backend.retirementRequests.map { request -> request.retiredThroughSequence }, + gap.name, + ) + val executions = storage.transaction { it.executions(RETIREMENT_CLIENT_ID) } + assertEquals(gap.phase, executions.single { it.clientSequence == 2L }.phase, gap.name) + assertEquals(StoredPhase.RETIRED, executions.single { it.clientSequence == 3L }.phase) + } + } + + /** + * Ordinary prune removes rows only at or below the persisted server-confirmed prefix; + * alias redirects and active or pending tombstone generations always survive. + */ + @Test + fun pruningNeverExceedsServerConfirmedRetiredPrefix() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = RetirementMutations() + val backend = FakeBackend() + val handle = RecordingRetirementHandle() + val source = MutationsTestKey("checkpoint-prune-source") + val canonical = MutationsTestKey("checkpoint-prune-canonical") + backend.redirectPresent(source, canonical) + backend.retireBehavior = { request -> + MutationRetirementAck(minOf(1L, request.retiredThroughSequence)) + } + val engine = + openRetirementEngine( + storage, + mutations, + backend, + handle, + TestWallClock(13_000L), + ) + + engine.mutate(source, mutations.set, "present") + engine.drain(source) + engine.mutate(canonical, mutations.deleteRef, Unit) + engine.drain(canonical) + + val durable = storage.retirementState() + val client = assertNotNull(durable.client) + assertEquals(2L, client.retiredThroughSequence) + assertEquals(1L, client.serverConfirmedRetiredThroughSequence) + assertEquals(listOf(1L, 2L), backend.retirementRequests.map { it.retiredThroughSequence }) + assertEquals(listOf(2L), durable.intents.map { it.clientSequence }) + assertEquals(listOf(2L), durable.executions.map { it.clientSequence }) + assertEquals(MutationAliasState.ACTIVE, durable.aliases.single().state) + val activeTombstone = durable.tombstones.single() + assertEquals(MutationTombstoneState.ACTIVE, activeTombstone.state) + assertEquals(2L, activeTombstone.createdBySequence) + } + + @Test + fun retirementCancellation_rethrowsWithoutConfirmingOrPruning_andRetriesMonotonically() = + runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = RetirementMutations() + val backend = FakeBackend() + val handle = RecordingRetirementHandle() + val key = MutationsTestKey("checkpoint-cancel") + var cancelNext = true + backend.retireBehavior = { request -> + if (cancelNext) { + cancelNext = false + throw CancellationException("checkpoint outcome unknown") + } + MutationRetirementAck(request.retiredThroughSequence) + } + val engine = + openRetirementEngine( + storage, + mutations, + backend, + handle, + TestWallClock(14_000L), + ) + engine.mutate(key, mutations.set, "retired") + + assertFailsWith { engine.drain(key) } + + val cancelled = assertNotNull(storage.transaction { it.client(RETIREMENT_CLIENT_ID) }) + assertEquals(1L, cancelled.retiredThroughSequence) + assertEquals(0L, cancelled.serverConfirmedRetiredThroughSequence) + assertEquals(listOf(1L), backend.retirementRequests.map { it.retiredThroughSequence }) + assertEquals(1, storage.transaction { it.intents(RETIREMENT_CLIENT_ID).size }) + + engine.drain() + + val retried = assertNotNull(storage.transaction { it.client(RETIREMENT_CLIENT_ID) }) + assertEquals(1L, retried.serverConfirmedRetiredThroughSequence) + assertEquals(listOf(1L, 1L), backend.retirementRequests.map { it.retiredThroughSequence }) + assertTrue(storage.transaction { it.intents(RETIREMENT_CLIENT_ID).isEmpty() }) + } + + @Test + fun checkpointFailureEmitsClientScopedEventWithoutMutationIdentity() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = RetirementMutations() + val backend = FakeBackend() + val key = MutationsTestKey("checkpoint-failure") + backend.retireBehavior = { + throw IllegalStateException("retirement transport failed\nraw throwable context") + } + val engine = + openRetirementEngine( + storage, + mutations, + backend, + RecordingRetirementHandle(), + TestWallClock(15_000L), + ) + engine.mutate(key, mutations.set, "retired") + val event = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.eventBus.events.filterIsInstance().first() + } + + engine.drain(key) + runCurrent() + + assertTrue(event.isCompleted, "checkpoint failure event was not emitted") + val failed = event.await() + val root: MutationEvent = failed + assertFalse(root is MutationIntentEvent) + assertEquals(RETIREMENT_CLIENT_ID, failed.clientId) + assertEquals(1L, failed.requestedThroughSequence) + assertEquals(MutationFailureKind.TRANSPORT, failed.failure.kind) + assertEquals("retirement transport failed", failed.failure.message) + assertFalse(failed.failure.message.contains("Throwable")) + assertFalse(failed.failure.message.contains('\n')) + assertTrue(storage.transaction { it.failures(RETIREMENT_CLIENT_ID).isEmpty() }) + val client = assertNotNull(storage.transaction { it.client(RETIREMENT_CLIENT_ID) }) + assertEquals(1L, client.retiredThroughSequence) + assertEquals(0L, client.serverConfirmedRetiredThroughSequence) + } + + @Test + fun checkpointSuccessEmitsPersistedConfirmedPrefix() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = RetirementMutations() + val backend = FakeBackend() + val key = MutationsTestKey("checkpoint-success") + backend.retireBehavior = { request -> + MutationRetirementAck( + confirmedThroughSequence = minOf(1L, request.retiredThroughSequence), + ) + } + val engine = + openRetirementEngine( + storage, + mutations, + backend, + RecordingRetirementHandle(), + TestWallClock(16_000L), + ) + engine.mutate(key, mutations.set, "first") + engine.mutate(key, mutations.set, "second") + val event = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.eventBus.events.filterIsInstance().first() + } + + engine.drain(key) + runCurrent() + + assertTrue(event.isCompleted, "checkpoint success event was not emitted") + val confirmed = event.await() + val persisted = assertNotNull(storage.transaction { it.client(RETIREMENT_CLIENT_ID) }) + assertEquals(RETIREMENT_CLIENT_ID, confirmed.clientId) + assertEquals(listOf(2L), backend.retirementRequests.map { it.retiredThroughSequence }) + assertEquals(2L, confirmed.requestedThroughSequence) + assertEquals(1L, persisted.serverConfirmedRetiredThroughSequence) + assertEquals(persisted.serverConfirmedRetiredThroughSequence, confirmed.confirmedThroughSequence) + assertEquals(1L, confirmed.confirmedThroughSequence) + assertTrue(storage.transaction { it.failures(RETIREMENT_CLIENT_ID).isEmpty() }) + } + + @Test + fun tombstoneReplayFollowsCanonicalAlias() = runTest { + val storage = InMemoryMutationJournalStorage() + val mutations = RetirementMutations() + val source = MutationsTestKey("replay-source") + val middle = MutationsTestKey("replay-middle") + val canonical = MutationsTestKey("replay-canonical") + seedTombstoneReplay(storage, mutations, source, middle, canonical) + val engine = + openRetirementEngine( + storage = storage, + mutations = mutations, + backend = FakeBackend(), + handle = RecordingRetirementHandle(), + clock = TestWallClock(20_000L), + ) + + engine.ensureHydrated() + val errors = mutableListOf() + expectRetirement(errors, "the source alias resolves to the tombstone identity") { + assertEquals(canonical.identity(), engine.terminalIdentityOf(source.identity())) + } + expectRetirement(errors, "the middle alias resolves to the tombstone identity") { + assertEquals(canonical.identity(), engine.terminalIdentityOf(middle.identity())) + } + expectRetirement(errors, "the canonical identity remains terminal") { + assertEquals(canonical.identity(), engine.terminalIdentityOf(canonical.identity())) + } + val expectedReplay = listOf(POST_TOMBSTONE_MUTATION_ID) + expectRetirement(errors, "source pending follows the active tombstone through both aliases") { + assertEquals(expectedReplay, engine.pending(source).map { it.mutationId }) + } + expectRetirement(errors, "middle pending follows the active tombstone through its alias") { + assertEquals(expectedReplay, engine.pending(middle).map { it.mutationId }) + } + expectRetirement(errors, "canonical pending excludes only the pre-watermark replay") { + assertEquals(expectedReplay, engine.pending(canonical).map { it.mutationId }) + } + expectRetirement(errors, "global pending retains the post-watermark control") { + assertEquals(expectedReplay, engine.pendingWrites().map { it.mutationId }) + } + expectRetirement(errors, "overlay replay applies only the post-watermark mutation") { + assertEquals("base+fresh", engine.overlay.apply(canonical, "base")) + } + expectRetirement( + errors, + "a lower-sequence causal Present supersedes the retired Delete and preserves interposed rows", + ) { + assertLowerSequenceCausalPresentRehomesInterposedRows() + } + expectRetirement(errors, "a lower-sequence causal Present replaces a retired target Present") { + assertRetiredTargetPresentCanBeCausallyReplaced() + } + expectRetirement(errors, "lost-ack restart retains unique namespace authority") { + assertLostAckOwnerSurvivesRestart() + } + expectRetirement(errors, "ACKED restart resumes without repushing the acknowledged generation") { + assertAckedRestartDoesNotRepush() + } + expectRetirement(errors, "alias fan-in supersedes every collapsed active tombstone") { + assertAliasFanInSupersedesEveryCollapsedWatermark() + } + expectRetirement(errors, "kill-before and kill-after finalization remain restart-safe") { + assertFinalizationKillWindows() + } + expectRetirement(errors, "retirement publishes routing, membership, and tombstones coherently") { + assertAliasRetirementPublicationIsCoherent() + } + assertTrue(errors.isEmpty(), errors.joinToString(separator = "\n")) + } +} + +private class RetirementMutations { + lateinit var set: MutatorRef + lateinit var append: MutatorRef + lateinit var park: MutatorRef + lateinit var deleteRef: MutatorRef + + val registry: MutatorRegistry = + mutatorRegistry { + set = + mutator( + id = "retirement-set", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { _, value -> MutationPresence.Present(value) } + append = + mutator( + id = "retirement-append", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { base, suffix -> + MutationPresence.Present( + (base as? MutationPresence.Present)?.value.orEmpty() + suffix, + ) + } + park = + mutator( + id = "retirement-park", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { _, _ -> + throw IllegalStateException("park the interposed mutation") + } + deleteRef = delete(id = "retirement-delete", stales = noStales()) + } +} + +private class RecordingRetirementHandle : StoreWriteHandle { + private val values = mutableMapOf() + private val confirmedAbsent = mutableSetOf() + + fun read(key: MutationsTestKey): String? = + if (key.identity() in confirmedAbsent) null else values[key.identity()] ?: "base" + + fun valueAt(key: MutationsTestKey): String? = read(key) + + override suspend fun apply( + key: MutationsTestKey, + value: String, + ) { + values[key.identity()] = value + confirmedAbsent -= key.identity() + } + + override suspend fun markStale(key: MutationsTestKey) = Unit + + override suspend fun confirmFresh( + key: MutationsTestKey, + etag: String?, + ) = Unit + + fun recordClear(key: MutationsTestKey) { + values.remove(key.identity()) + confirmedAbsent += key.identity() + } +} + +private fun openRetirementEngine( + storage: MutationJournalStorage, + mutations: RetirementMutations, + backend: FakeBackend, + handle: RecordingRetirementHandle, + clock: TestWallClock, + resolver: MutationKeyResolver = MutationsTestKeyResolver, +): MutationEngine = + openRetirementFixture(storage, mutations, backend, handle, clock, resolver).engine + +private data class RetirementEngineFixture( + val engine: MutationEngine, + val journal: StorageBackedMutationJournal, +) + +private fun openRetirementFixture( + storage: MutationJournalStorage, + mutations: RetirementMutations, + backend: FakeBackend, + handle: RecordingRetirementHandle, + clock: TestWallClock, + resolver: MutationKeyResolver = MutationsTestKeyResolver, +): RetirementEngineFixture { + val journal = + StorageBackedMutationJournal( + storage = storage, + registrations = mutations.registry.registrations, + clientId = RETIREMENT_CLIENT_ID, + hydrateOnFirstUse = true, + ) + val engine = + MutationEngine( + registry = mutations.registry, + server = backend, + journal = journal, + keyResolver = resolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + baseReader = { key -> handle.read(key) }, + absentAdoption = { key -> handle.recordClear(key) }, + wallClock = clock, + clientId = RETIREMENT_CLIENT_ID, + ).also { it.bind(handle) } + return RetirementEngineFixture(engine, journal) +} + +private suspend fun assertLowerSequenceCausalPresentRehomesInterposedRows() { + val storage = InMemoryMutationJournalStorage() + val mutations = RetirementMutations() + val backend = retainingRetirementBackend() + val handle = RecordingRetirementHandle() + val clock = TestWallClock(30_000L) + val source = MutationsTestKey("causal-source") + val target = MutationsTestKey("causal-target") + backend.redirectPresent(source, target) + val engine = openRetirementEngine(storage, mutations, backend, handle, clock) + + val sourcePresent = engine.mutate(source, mutations.set, "causal") + val executable = engine.mutate(source, mutations.append, "+q") + val parked = engine.mutate(source, mutations.park, "ignored") + val targetDelete = engine.mutate(target, mutations.deleteRef, Unit) + engine.drain(target) + + val deleted = storage.retirementState() + val sourceSequence = deleted.sequenceOf(sourcePresent) + val deleteSequence = deleted.sequenceOf(targetDelete) + assertTrue(sourceSequence < deleteSequence) + assertEquals(StoredPhase.RETIRED, deleted.executionOf(targetDelete).phase) + assertActiveTombstone( + deleted.tombstoneCreatedBy(deleteSequence), + deleted.executionOf(targetDelete).retiredAt, + ) + + engine.drain(source) + + val completed = storage.retirementState() + assertEquals(StoredPhase.RETIRED, completed.executionOf(sourcePresent).phase) + assertEquals(StoredPhase.RETIRED, completed.executionOf(executable).phase) + assertEquals(StoredPhase.PARKED, completed.executionOf(parked).phase) + assertSupersededTombstone( + record = completed.tombstoneCreatedBy(deleteSequence), + successorSequence = sourceSequence, + activatedAt = assertNotNull(deleted.tombstoneCreatedBy(deleteSequence).activatedAt), + supersededAt = assertNotNull(completed.executionOf(sourcePresent).retiredAt), + ) + assertEquals(target.identity(), engine.terminalIdentityOf(source.identity())) + assertEquals("causal+q", handle.valueAt(target)) + assertEquals(2L, assertNotNull(completed.client).retiredThroughSequence) + assertEquals( + listOf(target.identity(), source.identity(), target.identity()), + backend.receivedPushes.map { request -> request.key.identity() }, + ) + assertEquals(parked, engine.deadLetters().single().mutationId) +} + +private suspend fun assertRetiredTargetPresentCanBeCausallyReplaced() { + val storage = InMemoryMutationJournalStorage() + val mutations = RetirementMutations() + val backend = retainingRetirementBackend() + val handle = RecordingRetirementHandle() + val source = MutationsTestKey("present-source") + val target = MutationsTestKey("present-target") + backend.redirectPresent(source, target) + val engine = + openRetirementEngine(storage, mutations, backend, handle, TestWallClock(31_000L)) + + val sourcePresent = engine.mutate(source, mutations.set, "source-authority") + val targetPresent = engine.mutate(target, mutations.set, "target-authority") + engine.drain(target) + assertEquals(StoredPhase.RETIRED, storage.retirementState().executionOf(targetPresent).phase) + assertEquals("target-authority", handle.valueAt(target)) + + engine.drain(source) + + val completed = storage.retirementState() + assertTrue(completed.sequenceOf(sourcePresent) < completed.sequenceOf(targetPresent)) + assertEquals(StoredPhase.RETIRED, completed.executionOf(sourcePresent).phase) + assertEquals("source-authority", handle.valueAt(target)) + assertTrue(completed.tombstones.isEmpty()) + assertEquals( + listOf(target.identity(), source.identity()), + backend.receivedPushes.map { request -> request.key.identity() }, + ) +} + +private suspend fun assertLostAckOwnerSurvivesRestart() { + val storage = FailPointJournalStorage(InMemoryMutationJournalStorage()) + val mutations = RetirementMutations() + val backend = retainingRetirementBackend().apply { dedupingPushBehavior = true } + val handle = RecordingRetirementHandle() + val clock = TestWallClock(32_000L) + val source = MutationsTestKey("lost-ack-source") + val target = MutationsTestKey("lost-ack-target") + val other = + MutationsTestKey( + "lost-ack-other", + StoreNamespace("retirement-other-namespace"), + ) + backend.redirectPresent(source, target) + val first = + openRetirementEngine( + storage, + mutations, + backend, + handle, + clock, + RETIREMENT_ANY_NAMESPACE_RESOLVER, + ) + val sourceId = first.mutate(source, mutations.set, "source-old") + val targetId = first.mutate(target, mutations.set, "target-new") + val otherId = first.mutate(other, mutations.set, "other") + storage.armKillBeforeCommit(JournalFailPointBoundary.ACK_RECEIPT) + + val death = assertIs(captureRetirementFailure { first.drain(source) }) + assertFalse(death.committed) + assertEquals(StoredPhase.INFLIGHT, storage.retirementState().executionOf(sourceId).phase) + val sourceRequestsBeforeRestart = backend.receivedPushes.count { it.key.identity() == source.identity() } + + val reopened = + openRetirementEngine( + storage, + mutations, + backend, + handle, + clock, + RETIREMENT_ANY_NAMESPACE_RESOLVER, + ) + val transportBeforeNonowner = backend.receivedPushes.size + reopened.drain(target) + assertEquals(transportBeforeNonowner, backend.receivedPushes.size) + assertEquals(StoredPhase.UNPREPARED, storage.retirementState().executionOf(targetId).phase) + + reopened.drain(other) + assertEquals(StoredPhase.RETIRED, storage.retirementState().executionOf(otherId).phase) + assertEquals(StoredPhase.INFLIGHT, storage.retirementState().executionOf(sourceId).phase) + + reopened.drain(source) + val completed = storage.retirementState() + assertEquals(StoredPhase.RETIRED, completed.executionOf(sourceId).phase) + assertEquals(StoredPhase.RETIRED, completed.executionOf(targetId).phase) + assertEquals( + sourceRequestsBeforeRestart + 1, + backend.receivedPushes.count { it.key.identity() == source.identity() }, + ) + val sourceIdempotencyKey = + backend.receivedPushes.first { it.key.identity() == source.identity() }.idempotencyKey + assertEquals(1, backend.effectivePushApplications.count { it == sourceIdempotencyKey }) + assertEquals("target-new", handle.valueAt(target)) +} + +private suspend fun assertAckedRestartDoesNotRepush() { + val storage = FailPointJournalStorage(InMemoryMutationJournalStorage()) + val mutations = RetirementMutations() + val backend = retainingRetirementBackend().apply { dedupingPushBehavior = true } + val handle = RecordingRetirementHandle() + val clock = TestWallClock(33_000L) + val source = MutationsTestKey("acked-source") + val target = MutationsTestKey("acked-target") + backend.redirectPresent(source, target) + val first = openRetirementEngine(storage, mutations, backend, handle, clock) + val sourceId = first.mutate(source, mutations.set, "source") + val targetId = first.mutate(target, mutations.set, "target") + storage.armKillAfterCommit(JournalFailPointBoundary.ACK_RECEIPT) + + val death = assertIs(captureRetirementFailure { first.drain(source) }) + assertTrue(death.committed) + assertEquals(StoredPhase.ACKED, storage.retirementState().executionOf(sourceId).phase) + + val reopened = openRetirementEngine(storage, mutations, backend, handle, clock) + val beforeNonowner = backend.receivedPushes.size + reopened.drain(target) + assertEquals(beforeNonowner, backend.receivedPushes.size) + reopened.drain(source) + + assertEquals(1, backend.receivedPushes.count { it.key.identity() == source.identity() }) + val completed = storage.retirementState() + assertEquals(StoredPhase.RETIRED, completed.executionOf(sourceId).phase) + assertEquals(StoredPhase.RETIRED, completed.executionOf(targetId).phase) +} + +private suspend fun assertAliasFanInSupersedesEveryCollapsedWatermark() { + val storage = InMemoryMutationJournalStorage() + val mutations = RetirementMutations() + val backend = retainingRetirementBackend() + val handle = RecordingRetirementHandle() + val source = MutationsTestKey("fan-in-source") + val sibling = MutationsTestKey("fan-in-sibling") + val target = MutationsTestKey("fan-in-target") + seedAliasFanIn(storage, sibling, target) + backend.redirectPresent(source, target) + val engine = + openRetirementEngine(storage, mutations, backend, handle, TestWallClock(34_000L)) + + val mutationId = engine.mutate(source, mutations.set, "fan-in") + engine.drain(source) + + val completed = storage.retirementState() + val successor = completed.sequenceOf(mutationId) + val collapsed = + completed.tombstones.filter { tombstone -> + tombstone.namespace == target.namespace.value && + tombstone.canonicalId in setOf(sibling.canonicalId(), target.canonicalId()) + } + assertEquals(2, collapsed.size) + collapsed.forEach { tombstone -> + assertEquals(MutationTombstoneState.SUPERSEDED, tombstone.state) + assertEquals(RETIREMENT_CLIENT_ID, tombstone.supersededByClientId) + assertEquals(successor, tombstone.supersededBySequence) + assertNotNull(tombstone.activatedAt) + assertNotNull(tombstone.supersededAt) + } + assertEquals(target.identity(), engine.terminalIdentityOf(source.identity())) + assertEquals(target.identity(), engine.terminalIdentityOf(sibling.identity())) +} + +private suspend fun assertFinalizationKillWindows() { + val before = prepareLowerSequenceCausalRetirement("kill-before") + before.storage.armKillBeforeCommit(JournalFailPointBoundary.FINALIZATION) + val beforeDeath = + assertIs( + captureRetirementFailure { before.engine.drain(before.source) }, + ) + assertFalse(beforeDeath.committed) + val held = before.storage.retirementState() + assertEquals(StoredPhase.EFFECTS_PENDING, held.executionOf(before.sourceMutationId).phase) + assertEquals(MutationAliasState.PENDING, held.aliases.single().state) + assertActiveTombstone( + held.tombstoneCreatedBy(held.sequenceOf(before.deleteMutationId)), + held.executionOf(before.deleteMutationId).retiredAt, + ) + val sourcePushesBeforeRestart = + before.backend.receivedPushes.count { it.key.identity() == before.source.identity() } + val beforeReopened = + openRetirementEngine( + before.storage, + before.mutations, + before.backend, + before.handle, + before.clock, + ) + beforeReopened.drain(before.source) + assertEquals( + sourcePushesBeforeRestart, + before.backend.receivedPushes.count { it.key.identity() == before.source.identity() }, + ) + assertEquals( + StoredPhase.RETIRED, + before.storage.retirementState().executionOf(before.sourceMutationId).phase, + ) + + val after = prepareLowerSequenceCausalRetirement("kill-after") + after.storage.armKillAfterCommit(JournalFailPointBoundary.FINALIZATION) + val afterDeath = + assertIs( + captureRetirementFailure { after.engine.drain(after.source) }, + ) + assertTrue(afterDeath.committed) + val committed = after.storage.retirementState() + assertEquals(StoredPhase.RETIRED, committed.executionOf(after.sourceMutationId).phase) + assertEquals(MutationAliasState.ACTIVE, committed.aliases.single().state) + val afterPushes = after.backend.receivedPushes.size + val afterReopened = + openRetirementEngine( + after.storage, + after.mutations, + after.backend, + after.handle, + after.clock, + ) + afterReopened.drain(after.source) + assertEquals(afterPushes, after.backend.receivedPushes.size) +} + +private suspend fun TestScope.assertAliasRetirementPublicationIsCoherent() { + val storage = FailPointJournalStorage(InMemoryMutationJournalStorage()) + val mutations = RetirementMutations() + val backend = + retainingRetirementBackend().apply { + retireBehavior = { + throw IllegalStateException("publication checkpoint unavailable") + } + } + val handle = RecordingRetirementHandle() + val clock = TestWallClock(35_000L) + val target = MutationsTestKey("publication-target") + val source = MutationsTestKey("publication-source") + backend.redirectPresent(source, target) + val fixture = openRetirementFixture(storage, mutations, backend, handle, clock) + val deleteId = fixture.engine.mutate(target, mutations.deleteRef, Unit) + fixture.engine.drain(target) + val sourceId = fixture.engine.mutate(source, mutations.set, "published") + val contender = MutationsTestKey("publication-contender") + val contenderId = fixture.engine.mutate(contender, mutations.set, "queued") + storage.armFailTransaction(JournalFailPointBoundary.FINALIZATION) + assertIs( + captureRetirementFailure { fixture.engine.drain(source) }, + ) + + fixture.journal.runtimeState.mutex.lock() + val finalizer = backgroundScope.async { fixture.engine.drain(source) } + try { + runCurrent() + assertTrue(finalizer.isActive) + val durable = storage.retirementState() + assertEquals(StoredPhase.RETIRED, durable.executionOf(sourceId).phase) + assertEquals(MutationAliasState.ACTIVE, durable.aliases.single().state) + assertEquals( + MutationTombstoneState.SUPERSEDED, + durable.tombstoneCreatedBy(durable.sequenceOf(deleteId)).state, + ) + + val runtime = fixture.journal.runtimeSnapshot() + assertEquals(AliasEdgeState.PENDING, assertNotNull(runtime.aliases[source.identity()]).state) + assertEquals( + listOf(sourceId), + runtime.entries[source.identity()].orEmpty().map { entry -> entry.mutationId }, + ) + assertEquals( + MutationTombstoneState.ACTIVE, + fixture.engine.tombstoneSnapshot(target.identity()).single().state, + ) + + val pushesBeforeContender = backend.receivedPushes.size + val contenderPass = backgroundScope.async { fixture.engine.drain(contender) } + runCurrent() + assertTrue(contenderPass.isCompleted) + contenderPass.await() + assertEquals(pushesBeforeContender, backend.receivedPushes.size) + assertEquals(StoredPhase.UNPREPARED, storage.retirementState().executionOf(contenderId).phase) + } finally { + fixture.journal.runtimeState.mutex.unlock() + finalizer.await() + } + + val published = fixture.journal.runtimeSnapshot() + assertEquals(AliasEdgeState.ACTIVE, assertNotNull(published.aliases[source.identity()]).state) + assertTrue(source.identity() !in published.entries) + assertEquals( + MutationTombstoneState.SUPERSEDED, + fixture.engine.tombstoneSnapshot(target.identity()).single().state, + ) + assertEquals(1, backend.receivedPushes.count { it.key.identity() == source.identity() }) + + fixture.engine.drain(contender) + assertEquals(StoredPhase.RETIRED, storage.retirementState().executionOf(contenderId).phase) + assertEquals(1, backend.receivedPushes.count { it.key.identity() == contender.identity() }) +} + +private data class LowerSequenceCausalRetirement( + val storage: FailPointJournalStorage, + val mutations: RetirementMutations, + val backend: FakeBackend, + val handle: RecordingRetirementHandle, + val clock: TestWallClock, + val engine: MutationEngine, + val source: MutationsTestKey, + val sourceMutationId: String, + val deleteMutationId: String, +) + +private suspend fun prepareLowerSequenceCausalRetirement(label: String): LowerSequenceCausalRetirement { + val storage = FailPointJournalStorage(InMemoryMutationJournalStorage()) + val mutations = RetirementMutations() + val backend = retainingRetirementBackend() + val handle = RecordingRetirementHandle() + val clock = TestWallClock(36_000L) + val source = MutationsTestKey("$label-source") + val target = MutationsTestKey("$label-target") + backend.redirectPresent(source, target) + val engine = openRetirementEngine(storage, mutations, backend, handle, clock) + val sourceMutationId = engine.mutate(source, mutations.set, "$label-present") + val deleteMutationId = engine.mutate(target, mutations.deleteRef, Unit) + engine.drain(target) + return LowerSequenceCausalRetirement( + storage, + mutations, + backend, + handle, + clock, + engine, + source, + sourceMutationId, + deleteMutationId, + ) +} + +private fun retainingRetirementBackend(): FakeBackend = + FakeBackend().apply { + retireBehavior = { + MutationRetirementAck(confirmedThroughSequence = 0L) + } + } + +private fun FakeBackend.redirectPresent( + source: MutationsTestKey, + target: MutationsTestKey, +) { + pushBehavior = { key, value -> + MutationPresentAck( + authoritative = value, + etag = "retirement-${receivedPushes.size}", + canonicalKey = target.takeIf { key.identity() == source.identity() }, + ) + } +} + +private val RETIREMENT_ANY_NAMESPACE_RESOLVER = + MutationKeyResolver { identity -> + MutationsTestKey(identity.canonicalId, StoreNamespace(identity.namespace)) + } + +private suspend fun seedAliasFanIn( + storage: MutationJournalStorage, + sibling: MutationsTestKey, + target: MutationsTestKey, +) { + storage.transaction { transaction -> + insertActiveAlias(transaction, sibling, target, sequence = 91L) + insertActiveForeignTombstone(transaction, sibling, "fan-in-sibling-client", 7L, 700L) + insertActiveForeignTombstone(transaction, target, "fan-in-target-client", 8L, 800L) + } +} + +private fun insertActiveForeignTombstone( + transaction: MutationJournalTransaction, + key: MutationsTestKey, + clientId: String, + sequence: Long, + activatedAt: Long, +) { + val pending = + MutationKeyTombstoneRecord( + namespace = key.namespace.value, + canonicalId = key.canonicalId(), + createdByClientId = clientId, + createdBySequence = sequence, + state = MutationTombstoneState.PENDING, + createdAt = activatedAt - 1L, + activatedAt = null, + supersededByClientId = null, + supersededBySequence = null, + supersededAt = null, + ) + transaction.insertTombstone(pending) + transaction.advanceTombstone( + MutationKeyTombstoneRecord( + namespace = pending.namespace, + canonicalId = pending.canonicalId, + createdByClientId = pending.createdByClientId, + createdBySequence = pending.createdBySequence, + state = MutationTombstoneState.ACTIVE, + createdAt = pending.createdAt, + activatedAt = activatedAt, + supersededByClientId = null, + supersededBySequence = null, + supersededAt = null, + ), + ) +} + +private data class RetirementDurableState( + val client: MutationClientRecord?, + val intents: List, + val executions: List, + val aliases: List, + val tombstones: List, +) { + fun sequenceOf(mutationId: String): Long = + intents.single { it.mutationId == mutationId }.clientSequence + + fun executionOf(mutationId: String): MutationExecutionRecord { + val sequence = sequenceOf(mutationId) + return executions.single { it.clientSequence == sequence } + } + + fun tombstoneCreatedBy(sequence: Long): MutationKeyTombstoneRecord = + tombstones.single { + it.createdByClientId == RETIREMENT_CLIENT_ID && it.createdBySequence == sequence + } + + fun accountingFor(vararg mutationIds: String): RetirementAccounting = + RetirementAccounting( + retiredThroughSequence = assertNotNull(client).retiredThroughSequence, + executions = + mutationIds.associateWith { mutationId -> + val execution = executionOf(mutationId) + ExecutionAccounting( + phase = execution.phase, + generation = execution.currentGeneration, + retired = execution.retiredAt != null, + ) + }, + ) +} + +private data class RetirementAccounting( + val retiredThroughSequence: Long, + val executions: Map, +) + +private data class ExecutionAccounting( + val phase: StoredPhase, + val generation: Int, + val retired: Boolean, +) + +private enum class CheckpointGap( + val phase: StoredPhase, +) { + PARKED(StoredPhase.PARKED), + ACTIVE(StoredPhase.UNPREPARED), + EFFECTS_PENDING(StoredPhase.EFFECTS_PENDING), +} + +private suspend fun seedCheckpointGap( + storage: MutationJournalStorage, + gap: CheckpointGap, +) { + storage.transaction { transaction -> + transaction.insertClient( + MutationClientRecord(1, RETIREMENT_CLIENT_ID, 0L, 0L, 0L, 100L), + ) + transaction.advanceClient( + MutationClientRecord(1, RETIREMENT_CLIENT_ID, 3L, 0L, 0L, 100L), + ) + insertCheckpointIntent(transaction, sequence = 1L, namespace = "checkpoint-retired-1") + insertCheckpointIntent(transaction, sequence = 2L, namespace = "checkpoint-gap") + insertCheckpointIntent(transaction, sequence = 3L, namespace = "checkpoint-retired-3") + retireCheckpointIntent(transaction, sequence = 1L, namespace = "checkpoint-retired-1") + when (gap) { + CheckpointGap.PARKED -> { + val failure = + transaction.appendFailure( + clientId = RETIREMENT_CLIENT_ID, + clientSequence = 2L, + generation = 0, + kind = MutationFailureKind.CODEC, + detail = "checkpoint-gap", + message = "parked checkpoint gap", + occurredAt = 250L, + ) + transaction.advanceExecution( + MutationExecutionRecord( + clientId = RETIREMENT_CLIENT_ID, + clientSequence = 2L, + phase = StoredPhase.PARKED, + currentGeneration = 0, + attempt = 0, + lastAttemptAt = null, + activeFailureId = failure.failureId, + retiredAt = null, + ), + ) + } + CheckpointGap.ACTIVE -> Unit + CheckpointGap.EFFECTS_PENDING -> + acknowledgeCheckpointIntent( + transaction, + sequence = 2L, + namespace = "checkpoint-gap", + ) + } + retireCheckpointIntent(transaction, sequence = 3L, namespace = "checkpoint-retired-3") + transaction.advanceClient( + MutationClientRecord(1, RETIREMENT_CLIENT_ID, 3L, 1L, 0L, 100L), + ) + } +} + +private fun insertCheckpointIntent( + transaction: MutationJournalTransaction, + sequence: Long, + namespace: String, +) { + transaction.insertIntent( + recordVersion = 1, + clientId = RETIREMENT_CLIENT_ID, + clientSequence = sequence, + mutationId = "checkpoint-mutation-$sequence", + namespace = namespace, + canonicalId = "checkpoint-$sequence", + mutatorId = "retirement-set", + mutatorVersion = 1, + argsBlob = "value-$sequence".encodeToByteArray(), + idempotencyRoot = "$RETIREMENT_CLIENT_ID:$sequence", + createdAt = 100L + sequence, + ) + transaction.insertExecution( + MutationExecutionRecord( + clientId = RETIREMENT_CLIENT_ID, + clientSequence = sequence, + phase = StoredPhase.UNPREPARED, + currentGeneration = 0, + attempt = 0, + lastAttemptAt = null, + activeFailureId = null, + retiredAt = null, + ), + ) +} + +private fun acknowledgeCheckpointIntent( + transaction: MutationJournalTransaction, + sequence: Long, + namespace: String, +) { + transaction.insertAttempt( + MutationAttemptRecord( + clientId = RETIREMENT_CLIENT_ID, + clientSequence = sequence, + generation = 1, + effectiveNamespace = namespace, + effectiveCanonicalId = "checkpoint-$sequence", + valueCodecVersion = 1, + basePresence = MutationPresenceState.PRESENT, + baseBlob = "base".encodeToByteArray(), + minePresence = MutationPresenceState.PRESENT, + mineBlob = "value-$sequence".encodeToByteArray(), + preconditionMetaPresent = false, + preconditionWrittenAt = null, + preconditionEtag = null, + advertisedRetiredThroughSequence = 0L, + generationIdempotencyKey = "$RETIREMENT_CLIENT_ID:$sequence:g1", + preparedAt = 200L + sequence, + conflictMetaPresent = null, + conflictWrittenAt = null, + conflictEtag = null, + conflictReceivedAt = null, + ), + ) + transaction.advanceExecution(seedExecution(sequence, StoredPhase.READY, attempt = 0)) + transaction.advanceExecution(seedExecution(sequence, StoredPhase.INFLIGHT, attempt = 0)) + transaction.insertAck( + MutationAckRecord( + clientId = RETIREMENT_CLIENT_ID, + clientSequence = sequence, + generation = 1, + authoritativePresence = MutationPresenceState.PRESENT, + authoritativeBlob = "value-$sequence".encodeToByteArray(), + valueCodecVersion = 1, + etag = "checkpoint-$sequence", + canonicalTargetNamespace = null, + canonicalTargetId = null, + receivedAt = 300L + sequence, + ), + ) + transaction.advanceExecution( + seedExecution(sequence, StoredPhase.ACKED, attempt = 1, time = 300L + sequence), + ) + transaction.advanceExecution( + seedExecution(sequence, StoredPhase.EFFECTS_PENDING, attempt = 1, time = 300L + sequence), + ) +} + +private fun retireCheckpointIntent( + transaction: MutationJournalTransaction, + sequence: Long, + namespace: String, +) { + acknowledgeCheckpointIntent(transaction, sequence, namespace) + transaction.advanceExecution( + seedExecution( + sequence = sequence, + phase = StoredPhase.RETIRED, + attempt = 1, + time = 300L + sequence, + retiredAt = 400L + sequence, + ), + ) +} + +private suspend fun MutationJournalStorage.retirementState(): RetirementDurableState = + transaction { transaction -> + RetirementDurableState( + client = transaction.client(RETIREMENT_CLIENT_ID), + intents = transaction.intents(RETIREMENT_CLIENT_ID), + executions = transaction.executions(RETIREMENT_CLIENT_ID), + aliases = transaction.aliases(), + tombstones = transaction.tombstones(), + ) + } + +private fun assertPendingTombstone(record: MutationKeyTombstoneRecord) { + assertEquals(MutationTombstoneState.PENDING, record.state) + assertNull(record.activatedAt) + assertNull(record.supersededByClientId) + assertNull(record.supersededBySequence) + assertNull(record.supersededAt) +} + +private fun assertActiveTombstone( + record: MutationKeyTombstoneRecord, + activatedAt: Long?, +) { + assertEquals(MutationTombstoneState.ACTIVE, record.state) + assertEquals(assertNotNull(activatedAt), record.activatedAt) + assertNull(record.supersededByClientId) + assertNull(record.supersededBySequence) + assertNull(record.supersededAt) +} + +private fun assertSupersededTombstone( + record: MutationKeyTombstoneRecord, + successorSequence: Long, + activatedAt: Long, + supersededAt: Long, +) { + assertEquals(MutationTombstoneState.SUPERSEDED, record.state) + assertEquals(activatedAt, record.activatedAt) + assertEquals(RETIREMENT_CLIENT_ID, record.supersededByClientId) + assertEquals(successorSequence, record.supersededBySequence) + assertEquals(supersededAt, record.supersededAt) +} + +private suspend fun seedTombstoneReplay( + storage: MutationJournalStorage, + mutations: RetirementMutations, + source: MutationsTestKey, + middle: MutationsTestKey, + canonical: MutationsTestKey, +) { + storage.transaction { transaction -> + transaction.insertClient( + MutationClientRecord(1, RETIREMENT_CLIENT_ID, 0L, 0L, 0L, 100L), + ) + transaction.advanceClient( + MutationClientRecord(1, RETIREMENT_CLIENT_ID, 3L, 0L, 0L, 100L), + ) + insertReplayIntent( + transaction = transaction, + sequence = 1L, + mutationId = PRE_TOMBSTONE_MUTATION_ID, + key = source, + mutatorId = mutations.append.id, + argsBlob = "+stale".encodeToByteArray(), + ) + insertReplayIntent( + transaction = transaction, + sequence = 2L, + mutationId = TOMBSTONE_MUTATION_ID, + key = canonical, + mutatorId = mutations.deleteRef.id, + argsBlob = ByteArray(0), + ) + insertReplayIntent( + transaction = transaction, + sequence = 3L, + mutationId = POST_TOMBSTONE_MUTATION_ID, + key = middle, + mutatorId = mutations.append.id, + argsBlob = "+fresh".encodeToByteArray(), + ) + retireSeedDelete(transaction, canonical) + insertActiveAlias(transaction, source, middle, sequence = 11L) + insertActiveAlias(transaction, middle, canonical, sequence = 12L) + } +} + +private fun insertReplayIntent( + transaction: MutationJournalTransaction, + sequence: Long, + mutationId: String, + key: MutationsTestKey, + mutatorId: String, + argsBlob: ByteArray, +) { + transaction.insertIntent( + recordVersion = 1, + clientId = RETIREMENT_CLIENT_ID, + clientSequence = sequence, + mutationId = mutationId, + namespace = key.namespace.value, + canonicalId = key.canonicalId(), + mutatorId = mutatorId, + mutatorVersion = 1, + argsBlob = argsBlob, + idempotencyRoot = "$RETIREMENT_CLIENT_ID:$sequence", + createdAt = 100L + sequence, + ) + transaction.insertExecution( + MutationExecutionRecord( + clientId = RETIREMENT_CLIENT_ID, + clientSequence = sequence, + phase = StoredPhase.UNPREPARED, + currentGeneration = 0, + attempt = 0, + lastAttemptAt = null, + activeFailureId = null, + retiredAt = null, + ), + ) +} + +private fun retireSeedDelete( + transaction: MutationJournalTransaction, + canonical: MutationsTestKey, +) { + val sequence = 2L + transaction.insertAttempt( + MutationAttemptRecord( + clientId = RETIREMENT_CLIENT_ID, + clientSequence = sequence, + generation = 1, + effectiveNamespace = canonical.namespace.value, + effectiveCanonicalId = canonical.canonicalId(), + valueCodecVersion = 1, + basePresence = MutationPresenceState.PRESENT, + baseBlob = "base".encodeToByteArray(), + minePresence = MutationPresenceState.ABSENT, + mineBlob = null, + preconditionMetaPresent = false, + preconditionWrittenAt = null, + preconditionEtag = null, + advertisedRetiredThroughSequence = 0L, + generationIdempotencyKey = "$RETIREMENT_CLIENT_ID:$sequence:g1", + preparedAt = 200L, + conflictMetaPresent = null, + conflictWrittenAt = null, + conflictEtag = null, + conflictReceivedAt = null, + ), + ) + transaction.advanceExecution(seedExecution(sequence, StoredPhase.READY, attempt = 0)) + transaction.advanceExecution(seedExecution(sequence, StoredPhase.INFLIGHT, attempt = 0)) + transaction.insertAck( + MutationAckRecord( + clientId = RETIREMENT_CLIENT_ID, + clientSequence = sequence, + generation = 1, + authoritativePresence = MutationPresenceState.ABSENT, + authoritativeBlob = null, + valueCodecVersion = 1, + etag = "deleted", + canonicalTargetNamespace = null, + canonicalTargetId = null, + receivedAt = 300L, + ), + ) + val pending = + MutationKeyTombstoneRecord( + namespace = canonical.namespace.value, + canonicalId = canonical.canonicalId(), + createdByClientId = RETIREMENT_CLIENT_ID, + createdBySequence = sequence, + state = MutationTombstoneState.PENDING, + createdAt = 300L, + activatedAt = null, + supersededByClientId = null, + supersededBySequence = null, + supersededAt = null, + ) + transaction.insertTombstone(pending) + transaction.advanceExecution(seedExecution(sequence, StoredPhase.ACKED, attempt = 1, time = 300L)) + transaction.advanceExecution( + seedExecution(sequence, StoredPhase.EFFECTS_PENDING, attempt = 1, time = 300L), + ) + transaction.advanceTombstone( + MutationKeyTombstoneRecord( + namespace = pending.namespace, + canonicalId = pending.canonicalId, + createdByClientId = pending.createdByClientId, + createdBySequence = pending.createdBySequence, + state = MutationTombstoneState.ACTIVE, + createdAt = pending.createdAt, + activatedAt = 400L, + supersededByClientId = null, + supersededBySequence = null, + supersededAt = null, + ), + ) + transaction.advanceExecution( + seedExecution(sequence, StoredPhase.RETIRED, attempt = 1, time = 300L, retiredAt = 400L), + ) +} + +private fun seedExecution( + sequence: Long, + phase: StoredPhase, + attempt: Int, + time: Long? = null, + retiredAt: Long? = null, +): MutationExecutionRecord = + MutationExecutionRecord( + clientId = RETIREMENT_CLIENT_ID, + clientSequence = sequence, + phase = phase, + currentGeneration = 1, + attempt = attempt, + lastAttemptAt = time, + activeFailureId = null, + retiredAt = retiredAt, + ) + +private fun insertActiveAlias( + transaction: MutationJournalTransaction, + source: MutationsTestKey, + target: MutationsTestKey, + sequence: Long, +) { + val pending = + MutationKeyAliasRecord( + sourceNamespace = source.namespace.value, + sourceCanonicalId = source.canonicalId(), + targetNamespace = target.namespace.value, + targetCanonicalId = target.canonicalId(), + state = MutationAliasState.PENDING, + createdByClientId = "alias-client", + createdBySequence = sequence, + createdAt = 500L + sequence, + activatedAt = null, + ) + transaction.insertAlias(pending) + transaction.advanceAlias( + MutationKeyAliasRecord( + sourceNamespace = pending.sourceNamespace, + sourceCanonicalId = pending.sourceCanonicalId, + targetNamespace = pending.targetNamespace, + targetCanonicalId = pending.targetCanonicalId, + state = MutationAliasState.ACTIVE, + createdByClientId = pending.createdByClientId, + createdBySequence = pending.createdBySequence, + createdAt = pending.createdAt, + activatedAt = 600L + sequence, + ), + ) +} + +private suspend fun captureRetirementFailure(block: suspend () -> Unit): Throwable? = + try { + block() + null + } catch (failure: Throwable) { + failure + } + +private suspend fun expectRetirement( + errors: MutableList, + label: String, + assertion: suspend () -> Unit, +) { + try { + assertion() + } catch (failure: Throwable) { + errors += "$label: ${failure.message}" + } +} + +private const val RETIREMENT_CLIENT_ID = "client-0" +private const val PRE_TOMBSTONE_MUTATION_ID = "pre-tombstone-replay" +private const val TOMBSTONE_MUTATION_ID = "active-delete" +private const val POST_TOMBSTONE_MUTATION_ID = "post-tombstone-replay" + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = 25.seconds, testBody = testBody) diff --git a/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationSourceOfTruthContractTest.kt b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationSourceOfTruthContractTest.kt new file mode 100644 index 000000000..6e2da79d1 --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationSourceOfTruthContractTest.kt @@ -0,0 +1,46 @@ +package org.mobilenativefoundation.store6.mutations + +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import org.mobilenativefoundation.store6.testing.SourceOfTruthContractKit + +/** + * Certifies the mutations-owned default [MutationSourceOfTruth] against the read-only + * store6-testing contract kit: the default may not reuse core's inaccessible internal + * implementation, and may not weaken reader liveness or mutation exception atomicity. + * + * Every inherited kit member is a binding test contract and runs on every compiled target: + * `readerStaysLiveAcrossDelete`, `readerFirstEmissionIsCurrentValue`, + * `readerFirstEmissionIsNullWhenAbsent`, `deleteEmitsNull`, `readerNeverCompletesNormally`, + * `readerStaysLiveAcrossThreeDeleteCycles`, `equalValueRewriteEmits`, `writeIsVisibleToLateReader`, + * `deleteIsVisibleToLateReader`, `keysAreIsolated`, `twoConcurrentReadersBothSeeWrite`, + * `deleteNamespaceDeletesOnlyMatchingNamespace`, `deleteNamespaceEmitsNullToActiveMatchingReader`, + * `deleteAllDeletesEveryNamespaceAndEmitsNull`, and `readerStaysLiveAcrossNamespaceDelete`. + * This subclass adds nothing and overrides nothing beyond the fixtures, so the kit alone owns the + * assertions. The dedicated two-namespace key type exists because the shared `MutationsTestKey` + * fixture pins one namespace and the kit requires a cross-namespace key. + */ +@OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) +class MutationSourceOfTruthContractTest : + SourceOfTruthContractKit() { + class ContractKey( + ns: String, + private val id: String, + ) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace(ns) + + override fun canonicalId(): String = id + } + + override fun createSourceOfTruth(): SourceOfTruth = + MutationSourceOfTruth() + + override val keyA = ContractKey("mutations-sot-kit", "a") + override val keyB = ContractKey("mutations-sot-kit", "b") + override val keyOtherNamespace = ContractKey("mutations-sot-kit-other", "a") + + override fun value(index: Int): String = "value-$index" +} diff --git a/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilderTest.kt b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilderTest.kt new file mode 100644 index 000000000..be2d71ebf --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilderTest.kt @@ -0,0 +1,435 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, +) + +package org.mobilenativefoundation.store6.mutations + +import app.cash.turbine.test +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.seam.FetchPlan +import org.mobilenativefoundation.store6.core.seam.Fetcher +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import org.mobilenativefoundation.store6.core.seam.FreshnessContext +import org.mobilenativefoundation.store6.core.seam.FreshnessValidator +import org.mobilenativefoundation.store6.core.seam.KeyEvents +import org.mobilenativefoundation.store6.core.seam.StoreTelemetry +import org.mobilenativefoundation.store6.testing.FakeBookkeeper +import org.mobilenativefoundation.store6.testing.FakeSourceOfTruth +import org.mobilenativefoundation.store6.testing.TestWallClock +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +/** + * The builder and factory forwarding contract. + * + * `MutationStoreBuilder` mirrors core's optional doors, exposes no overlay door, and retains the + * exact selected-or-default Bookkeeper and SourceOfTruth for both the delegated core Store and + * the mutation engine. ABI absence of an overlay setter, runtime, and write-handle exposure is + * proved by the jvmTest dump test + * (`MutationApiSurfaceTest.apiDumpContainsNoOverlaySetterRuntimeOrWriteHandleExposure`) plus + * `:store6-mutations:apiCheck`; this common suite proves the doors' forwarding behavior. + */ +class MutationStoreBuilderTest { + @Test + fun factory_requiresRegistryServerResolverAndValueCodec() = runTest { + // The registry, server, keyResolver, valueCodecVersion, and valueCodec are required + // factory parameters, never builder doors. The named-argument call pins the exact + // spelling; a functional read and mutate prove each required input reached the store. + val mutators = testMutators() + val store = + mutationStore( + registry = mutators.registry, + server = BuilderRecordingMutationServer(), + keyResolver = MutationKeyResolver { identity -> MutationsTestKey(identity.canonicalId) }, + valueCodecVersion = 1, + valueCodec = StringMutationCodec, + configure = { + fetcher { "confirmed" } + }, + ) + try { + val key = MutationsTestKey("factory-required") + assertEquals("confirmed", store.get(key)) + val mutationId = store.mutate(key, mutators.append, "+mine") + assertTrue(mutationId.isNotEmpty()) + } finally { + store.close() + } + } + + @Test + fun explicitBookkeeper_isSameInstanceForStoreAndMutationEngine() = runTest { + val mutators = testMutators() + val bookkeeper = FakeBookkeeper() + val store = + buildStore(mutators) { + fetcher { "confirmed" } + bookkeeper(bookkeeper) + } + try { + val key = MutationsTestKey("explicit-bookkeeper") + assertEquals("confirmed", store.get(key)) + // Store side: the fetch commit recorded success into the caller's exact instance. + val status = assertNotNull(bookkeeper.status(key)) + assertNotNull(status.meta) + // Engine side: the mutation engine retained the caller's exact instance. + assertSame(bookkeeper, store.bookkeeperRetainedByEngine) + } finally { + store.close() + } + } + + @Test + fun defaultBookkeeper_isSameInstanceForStoreAndMutationEngine() = runTest { + val mutators = testMutators() + val store = + buildStore(mutators) { + fetcher { "confirmed" } + } + try { + val key = MutationsTestKey("default-bookkeeper") + // The unset door installs one mutations-owned default, never core's internal one. + val retained = assertIs(store.bookkeeperRetainedByEngine) + assertEquals("confirmed", store.get(key)) + // The Store recorded its fetch success into the same instance the engine retained. + val status = assertNotNull(retained.status(key)) + assertNotNull(status.meta) + } finally { + store.close() + } + } + + @Test + fun explicitSourceOfTruth_isSameInstanceForStoreAndMutationEngine() = runTest { + val mutators = testMutators() + val sourceOfTruth = FakeSourceOfTruth() + val store = + buildStore(mutators) { + fetcher { "confirmed" } + persistence(sourceOfTruth) + } + try { + val key = MutationsTestKey("explicit-sot") + assertEquals("confirmed", store.get(key)) + // Store side: the engine persisted the fetched value through the caller's instance. + assertEquals("confirmed", sourceOfTruth.reader(key).first()) + // Engine side: the mutation engine retained the caller's exact instance. + assertSame(sourceOfTruth, store.sourceOfTruthRetainedByEngine) + } finally { + store.close() + } + } + + @Test + fun defaultSourceOfTruth_isSameInstanceForStoreAndMutationEngine() = runTest { + val mutators = testMutators() + val store = + buildStore(mutators) { + fetcher { "confirmed" } + } + try { + val key = MutationsTestKey("default-sot") + val retained = + assertIs>( + store.sourceOfTruthRetainedByEngine, + ) + assertEquals("confirmed", store.get(key)) + // The Store persisted through the same instance the engine retained. + assertEquals("confirmed", retained.reader(key).first()) + } finally { + store.close() + } + } + + @Test + fun builderForwardsEveryRuledConfigurationDoor() = runTest { + val mutators = testMutators() + val telemetry = RecordingTelemetry() + val validator = RecordingFreshnessValidator() + val wallClock = TestWallClock(startEpochMillis = 1_234L) + val sourceOfTruth = FakeSourceOfTruth() + val bookkeeper = FakeBookkeeper() + var preconditionInvocations = 0 + var mergeInvocations = 0 + val store = + buildStore(mutators) { + fetcherOfResult { FetcherResult.Success("confirmed", etag = "etag-1") } + persistence(sourceOfTruth) + telemetry(telemetry) + bookkeeper(bookkeeper) + wallClock(wallClock) + freshnessValidator(validator) + maxIdleKeys(3) + conflicts { + precondition { candidate -> + preconditionInvocations += 1 + candidate.capturedMeta + } + merge { _, mine, _ -> + mergeInvocations += 1 + MutationConflictResolution.Retry(mine) + } + } + } + try { + val key = MutationsTestKey("every-door") + assertEquals("confirmed", store.get(key)) + store.mutate(key, mutators.append, "+pending") + + // fetcherOfResult door reached the engine and produced the fetched value above. + // telemetry door: the configured observer saw the fetch lifecycle. + assertEquals(1, telemetry.fetchStartedKeys.size) + assertSame(key, telemetry.fetchStartedKeys.single()) + // freshnessValidator and wallClock doors: the configured planner ran against the + // configured clock's now. + assertTrue(validator.observedNowEpochMillis.isNotEmpty()) + assertTrue(validator.observedNowEpochMillis.all { now -> now == 1_234L }) + // bookkeeper and persistence doors: exact instances on the Store path... + assertNotNull(bookkeeper.status(key)) + assertEquals("confirmed", sourceOfTruth.reader(key).first()) + // ...and the same exact instances retained for the engine. + assertSame(bookkeeper, store.bookkeeperRetainedByEngine) + assertSame(sourceOfTruth, store.sourceOfTruthRetainedByEngine) + // conflicts door: nothing registered here runs for a merely pending mutation. The + // selector runs when a drain prepares a generation, the merge only on a conflict. + assertEquals(0, preconditionInvocations) + assertEquals(0, mergeInvocations) + } finally { + store.close() + } + } + + @Test + fun fetcherDoors_lastRegistrationWinsAcrossAllThreeForms() = runTest { + val mutators = testMutators() + val interfaceLast = + buildStore(mutators) { + fetcher { "from-lambda" } + fetcherOfResult { FetcherResult.Success("from-result-lambda") } + fetcher( + object : Fetcher { + override suspend fun fetch( + key: MutationsTestKey, + etag: String?, + ): FetcherResult = FetcherResult.Success("from-interface") + }, + ) + } + try { + assertEquals("from-interface", interfaceLast.get(MutationsTestKey("fetcher-forms-1"))) + } finally { + interfaceLast.close() + } + + val lambdaLast = + buildStore(mutators) { + fetcherOfResult { FetcherResult.Success("from-result-lambda") } + fetcher { "from-lambda" } + } + try { + assertEquals("from-lambda", lambdaLast.get(MutationsTestKey("fetcher-forms-2"))) + } finally { + lambdaLast.close() + } + } + + @Test + fun maxIdleKeys_rejectsNegativeCountAtTheDoor() = runTest { + val mutators = testMutators() + val failure = + assertFailsWith { + buildStore(mutators) { + fetcher { "confirmed" } + maxIdleKeys(-1) + } + } + assertEquals("maxIdleKeys must be >= 0, was -1.", failure.message) + } + + @Test + fun conflictsDoor_registrationValidatesSinglePreconditionAndSingleMerge() = runTest { + val mutators = testMutators() + assertFailsWith { + buildStore(mutators) { + fetcher { "confirmed" } + conflicts { + precondition { candidate -> candidate.capturedMeta } + precondition { null } + } + } + } + assertFailsWith { + buildStore(mutators) { + fetcher { "confirmed" } + conflicts { + merge { _, mine, _ -> MutationConflictResolution.Retry(mine) } + merge { _, _, _ -> MutationConflictResolution.ServerWins } + } + } + } + } + + @Test + fun keyEvents_isExactDelegateRuntimeFlow() = runTest { + // The facade re-exposes the delegate runtime's advisory flow unchanged. The + // factory captures `runtime.keyEvents` exactly once; aliasing adds no `Rekeyed` variant + // (KeyEvents constructors are core-internal, so this module cannot mint one), and the + // flow retains the core lifecycle contract: it never completes, even after close. + val mutators = testMutators() + val store = + buildStore(mutators) { + fetcher { "confirmed" } + } + val key = MutationsTestKey("advisory-key-events") + store.keyEvents.test { + assertEquals("confirmed", store.get(key)) + val written = assertIs(awaitItem()) + assertEquals(Origin.FETCHER, written.origin) + assertEquals(key.identity(), written.key.identity()) + + store.close() + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun pendingMutation_projectsThroughEngineOverlay_withoutPublicOverlayDoor() = runTest { + // The builder has no overlay door (compile-time absence; the ABI proof is the jvmTest + // dump test). The engine overlay installed by the factory is therefore the sole projection + // layer, and pending mutations project with OVERLAY origin through the facade. + val mutators = testMutators() + val store = + buildStore(mutators) { + fetcher { "base" } + } + try { + val key = MutationsTestKey("engine-overlay") + store.stream(key).test { + assertEquals("base", awaitData().value) + + store.mutate(key, mutators.append, "+pending") + var projected = awaitData() + while (projected.value != "base+pending") { + projected = awaitData() + } + assertEquals(Origin.OVERLAY, projected.origin) + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } +} + +/** Fixed-version UTF-8 codec for the String values and args used by this suite. */ +private object StringMutationCodec : MutationCodec { + override fun encode(value: String): ByteArray = value.encodeToByteArray() + + override fun decode( + version: Int, + bytes: ByteArray, + ): String = bytes.decodeToString() +} + +/** Minimal two-method server: acknowledges this client's value and confirms retirement. */ +private class BuilderRecordingMutationServer : MutationServer { + override suspend fun push( + request: MutationPush, + ): MutationAck = + when (val mine = request.mine) { + is MutationPresence.Present -> + MutationPresentAck( + authoritative = mine.value, + etag = null, + canonicalKey = null, + ) + MutationPresence.Absent -> MutationAbsentAck(etag = null) + } + + override suspend fun retire(request: MutationRetirement): MutationRetirementAck = + MutationRetirementAck(confirmedThroughSequence = request.retiredThroughSequence) +} + +private class RecordingTelemetry : StoreTelemetry { + val fetchStartedKeys = mutableListOf() + + override fun onFetchStarted(key: StoreKey) { + fetchStartedKeys += key + } +} + +private class RecordingFreshnessValidator : FreshnessValidator { + val observedNowEpochMillis = mutableListOf() + + override fun plan(context: FreshnessContext): FetchPlan { + observedNowEpochMillis += context.nowEpochMillis + return if (context.hasResidentValue) { + FetchPlan.Skip + } else { + FetchPlan.Fetch(servesResidentWhileFetching = false) + } + } +} + +private class TestMutators( + val registry: MutatorRegistry, + val append: MutatorRef, +) + +private fun testMutators(): TestMutators { + lateinit var append: MutatorRef + val registry = + mutatorRegistry { + append = + mutator( + id = "append", + version = 1, + codec = StringMutationCodec, + stales = { _, _ -> StaleSet(keys = emptySet(), namespaces = emptySet()) }, + project = { base, args -> + when (base) { + is MutationPresence.Present -> + MutationPresence.Present(base.value + args) + MutationPresence.Absent -> null + } + }, + ) + } + return TestMutators(registry = registry, append = append) +} + +private fun buildStore( + mutators: TestMutators, + configure: MutationStoreBuilder.() -> Unit, +): MutationStore = + mutationStore( + registry = mutators.registry, + server = BuilderRecordingMutationServer(), + keyResolver = MutationKeyResolver { identity -> MutationsTestKey(identity.canonicalId) }, + valueCodecVersion = 1, + valueCodec = StringMutationCodec, + configure = configure, + ) + +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } diff --git a/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationsTestFixtures.kt b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationsTestFixtures.kt new file mode 100644 index 000000000..7fb9461c3 --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationsTestFixtures.kt @@ -0,0 +1,311 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, +) + +package org.mobilenativefoundation.store6.mutations + +import app.cash.turbine.ReceiveTurbine +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.seam.FetcherResult + +/** + * The module's single test key. The namespace parameter exists for the alias-facade tests: + * a canonical target in another namespace must be constructible so cross-namespace + * rejection and full-pair (not canonical-id) routing are provable; every other test keeps the + * default `mutations` namespace. + */ +internal class MutationsTestKey( + private val id: String, + override val namespace: StoreNamespace = StoreNamespace("mutations"), +) : StoreKey { + override fun canonicalId(): String = id +} + +/** Exact-pair resolver for the module's single-namespace test key. */ +internal object MutationsTestKeyResolver : MutationKeyResolver { + override suspend fun resolve(identity: MutationKeyIdentity): MutationsTestKey? = + if (identity.namespace == "mutations") MutationsTestKey(identity.canonicalId) else null +} + +/** UTF-8 string args codec for registrations whose args survive an encode/decode round trip. */ +internal object FixtureStringArgsCodec : MutationCodec { + override fun encode(value: String): ByteArray = value.encodeToByteArray() + + override fun decode( + version: Int, + bytes: ByteArray, + ): String = bytes.decodeToString() +} + +/** Version-1 Unit codec for durable tests that exercise the complete encode/decode boundary. */ +internal object FixtureUnitArgsCodec : MutationCodec { + override fun encode(value: Unit): ByteArray = ByteArray(0) + + override fun decode( + version: Int, + bytes: ByteArray, + ) { + require(version == 1) { "Fixture Unit args require version 1; was $version." } + require(bytes.isEmpty()) { "Fixture Unit args require zero bytes; received ${bytes.size}." } + } +} + +/** Args codec for registrations whose codec is never exercised by the test at hand. */ +internal fun inertArgsCodec(): MutationCodec = + object : MutationCodec { + override fun encode(value: A): ByteArray = ByteArray(0) + + override fun decode( + version: Int, + bytes: ByteArray, + ): A = throw UnsupportedOperationException("This fixture codec does not decode.") + } + +/** Stale-set function for registrations that declare no invalidation effects. */ +internal fun noStales(): (K, A) -> StaleSet = + { _, _ -> StaleSet(keys = emptySet(), namespaces = emptySet()) } + +/** Default-preserving typed stale declaration for drain/effect orchestration tests. */ +internal fun typedStales( + keys: Set = emptySet(), + namespaces: Set = emptySet(), +): (MutationsTestKey, A) -> StaleSet = + { _, _ -> StaleSet(keys = keys, namespaces = namespaces) } + +/** + * Test-only dual-role backend for the mutations tracer. + * + * [MutationServer] intentionally has only push and retire. This fixture additionally exposes + * [load] for a Store fetcher and [loadResult] for a `fetcherOfResult` door, so [offline] can + * take the test's server reads and writes offline together. [pushBehavior] remains scriptable + * for present-projection acknowledgements and failures using the `(key, value)` shape the + * walking-skeleton tests script against — including a [MutationPresentAck] whose `canonicalKey` + * redirects a provisional identity; [absentPushBehavior] scripts Absent-projection pushes + * separately (delete is drainable and its `mine` is `Absent`). A returned + * [MutationAbsentAck] marks the entity deleted: the confirmed value is dropped and every fetch + * begun afterwards observes [FetcherResult.Deleted] through [loadResult] — the backend + * coherence obligation the ack certifies. Acknowledged state lands under the EFFECTIVE identity: + * a Present ack carrying a canonical key stores the authoritative value at the canonical + * identity, mirroring a backend whose entity truth lives at the canonical row. The library-built + * [MutationPush] carrier is unwrapped before scripting. + */ +internal class FakeBackend( + private val fallbackValue: String = "base", +) : MutationServer { + private val confirmed = mutableMapOf() + private val deletedIdentities = mutableSetOf() + + internal var offline: Boolean = false + internal var fetchCount: Int = 0 + private set + internal val pushedValues: MutableList = mutableListOf() + internal val receivedPushes: MutableList> = mutableListOf() + internal val receivedIdempotencyKeys: MutableList = mutableListOf() + internal val effectivePushApplications: MutableList = mutableListOf() + internal val retirementRequests: MutableList = mutableListOf() + + /** + * When enabled, a repeated generation idempotency key remains observable as another request + * but returns its stored acknowledgement without applying the request again. + */ + internal var dedupingPushBehavior: Boolean = false + + private val pushState = Mutex() + private val acknowledgementsByIdempotencyKey = + mutableMapOf>() + private val acknowledgementsInFlightByIdempotencyKey = + mutableMapOf>>() + internal var lastAck: MutationAck? = null + private set + + /** Optional gate awaited inside [loadResult] after its entry snapshot is taken. */ + internal var loadGate: (suspend (MutationsTestKey) -> Unit)? = null + + internal var pushBehavior: + suspend (MutationsTestKey, String) -> MutationAck = { _, value -> + MutationPresentAck( + authoritative = value, + etag = "etag-${pushedValues.size}", + canonicalKey = null, + ) + } + + internal var absentPushBehavior: + suspend (MutationsTestKey) -> MutationAck = { _ -> + MutationAbsentAck(etag = "etag-${receivedPushes.size}") + } + + internal var retireBehavior: + suspend (MutationRetirement) -> MutationRetirementAck = { request -> + MutationRetirementAck(confirmedThroughSequence = request.retiredThroughSequence) + } + + internal fun seed( + key: MutationsTestKey, + value: String, + ) { + confirmed[key.identity()] = value + } + + internal suspend fun load(key: MutationsTestKey): String { + fetchCount += 1 + check(!offline) { "backend is offline" } + return confirmed[key.identity()] ?: fallbackValue + } + + /** + * The rich-result fetch door. The result is snapshotted at entry — a fetch "begins" when it + * reaches the backend — so a gated in-flight fetch keeps its pre-gate snapshot even when a + * deletion is acknowledged while it waits, matching the ack's begun-after semantics. + */ + internal suspend fun loadResult(key: MutationsTestKey): FetcherResult { + fetchCount += 1 + check(!offline) { "backend is offline" } + val identity = key.identity() + val snapshot: FetcherResult = + if (identity in deletedIdentities) { + FetcherResult.Deleted + } else { + FetcherResult.Success(confirmed[identity] ?: fallbackValue) + } + loadGate?.invoke(key) + return snapshot + } + + override suspend fun push(request: MutationPush): MutationAck { + check(!offline) { "backend is offline" } + val idempotencyKey = request.idempotencyKey + var storedAck: MutationAck? = null + var inFlightAck: CompletableDeferred>? = null + var ownsInFlightAck = false + pushState.withLock { + receivedPushes += request + receivedIdempotencyKeys += idempotencyKey + if (dedupingPushBehavior) { + storedAck = acknowledgementsByIdempotencyKey[idempotencyKey] + if (storedAck == null) { + inFlightAck = acknowledgementsInFlightByIdempotencyKey[idempotencyKey] + if (inFlightAck == null) { + val createdAck = + CompletableDeferred>() + inFlightAck = createdAck + acknowledgementsInFlightByIdempotencyKey[idempotencyKey] = createdAck + ownsInFlightAck = true + } + } + } + } + + storedAck?.let { ack -> + pushState.withLock { lastAck = ack } + return ack + } + val pendingAck = inFlightAck + if (pendingAck != null && !ownsInFlightAck) { + val ack = pendingAck.await() + pushState.withLock { lastAck = ack } + return ack + } + + try { + val ack = + when (val mine = request.mine) { + is MutationPresence.Present -> { + pushState.withLock { pushedValues += mine.value } + pushBehavior(request.key, mine.value) + } + MutationPresence.Absent -> absentPushBehavior(request.key) + } + withContext(NonCancellable) { + pushState.withLock { + effectivePushApplications += idempotencyKey + when (ack) { + is MutationPresentAck -> { + // A canonical redirect means the entity's authoritative row IS the + // canonical identity; the acknowledged value lands there. + val effectiveIdentity = (ack.canonicalKey ?: request.key).identity() + confirmed[effectiveIdentity] = ack.authoritative + deletedIdentities -= effectiveIdentity + } + is MutationAbsentAck -> { + val identity = request.key.identity() + confirmed.remove(identity) + deletedIdentities += identity + } + } + lastAck = ack + if (ownsInFlightAck) { + acknowledgementsByIdempotencyKey[idempotencyKey] = ack + acknowledgementsInFlightByIdempotencyKey.remove(idempotencyKey) + checkNotNull(pendingAck).complete(ack) + } + } + } + return ack + } catch (failure: Throwable) { + if (ownsInFlightAck) { + withContext(NonCancellable) { + pushState.withLock { + acknowledgementsInFlightByIdempotencyKey.remove(idempotencyKey) + checkNotNull(pendingAck).completeExceptionally(failure) + } + } + } + throw failure + } + } + + override suspend fun retire(request: MutationRetirement): MutationRetirementAck { + val behavior = + pushState.withLock { + retirementRequests += request + retireBehavior + } + return behavior(request) + } +} + +internal fun echoingMutationServer(): MutationServer = + object : MutationServer { + override suspend fun push(request: MutationPush): MutationAck = + when (val mine = request.mine) { + is MutationPresence.Present -> + MutationPresentAck( + authoritative = mine.value, + etag = null, + canonicalKey = null, + ) + MutationPresence.Absent -> MutationAbsentAck(etag = null) + } + + override suspend fun retire(request: MutationRetirement): MutationRetirementAck = + MutationRetirementAck(confirmedThroughSequence = request.retiredThroughSequence) + } + +internal suspend fun ReceiveTurbine>.awaitData(): StoreResult.Data { + while (true) { + when (val result = awaitItem()) { + is StoreResult.Data -> return result + is StoreResult.Error, + is StoreResult.Loading, + is StoreResult.Revalidated, + -> Unit + } + } +} + +internal suspend fun ReceiveTurbine>.awaitConfirmed(): StoreResult.Data { + while (true) { + val result = awaitData() + if (result.origin != Origin.OVERLAY) return result + } +} diff --git a/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationsWalkingSkeletonTest.kt b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationsWalkingSkeletonTest.kt new file mode 100644 index 000000000..812991978 --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationsWalkingSkeletonTest.kt @@ -0,0 +1,254 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, +) + +package org.mobilenativefoundation.store6.mutations + +import app.cash.turbine.ReceiveTurbine +import app.cash.turbine.test +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.seam.KeyEvents +import org.mobilenativefoundation.store6.core.seam.runtime +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +class MutationsWalkingSkeletonTest { + @Test + fun offlineMutation_projectsOverlay_thenAckLandsConfirmedWithoutRefetch() = runTest { + lateinit var rename: MutatorRef + val registry = + mutatorRegistry { + rename = + mutator( + id = "rename", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { _, nextName -> MutationPresence.Present(nextName) } + } + val backend = + FakeBackend().apply { + pushBehavior = { _, value -> + MutationPresentAck( + authoritative = "confirmed:$value", + etag = "server-etag", + canonicalKey = null, + ) + } + } + val key = MutationsTestKey("quickstart") + val users = + mutationStore( + registry = registry, + server = backend, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + ) { + fetcher { backend.load(it) } + } + val hydrationWrittenAwaiter = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + users.keyEvents.first { event -> + event is KeyEvents.Written && + event.key === key && + event.origin == Origin.FETCHER + } as KeyEvents.Written + } + + try { + users.stream(key).test { + awaitValue("base") + val hydrationWritten = hydrationWrittenAwaiter.await() + assertSame(key, hydrationWritten.key) + assertEquals(Origin.FETCHER, hydrationWritten.origin) + val fetchesAfterHydration = backend.fetchCount + backend.offline = true + + val mutationId = users.mutate(key, rename, "optimistic") + assertTrue(mutationId.isNotBlank()) + assertEquals(emptyList(), backend.pushedValues) + assertEquals( + listOf(mutationId), + users.pending(key).map(PendingIntent::mutationId), + ) + + val optimistic = awaitOverlayValue("optimistic") + assertEquals(Origin.OVERLAY, optimistic.origin) + + // KeyEvents has replay = 0. UNDISPATCHED enters first() before async returns, so + // this collector is subscribed before drain(key) can emit Written. + val writtenAwaiter = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + users.keyEvents.first { event -> + event is KeyEvents.Written && + event.key === key && + event.origin == Origin.SOT + } as KeyEvents.Written + } + try { + backend.offline = false + users.drain(key) + assertEquals(listOf("optimistic"), backend.pushedValues) + + val confirmed = awaitConfirmedWithoutOldBase("confirmed:optimistic", "base") + assertTrue( + confirmed.origin == Origin.SOT || confirmed.origin == Origin.MEMORY, + "expected SOT or MEMORY after ack, was ${confirmed.origin}", + ) + + val written = writtenAwaiter.await() + assertSame(key, written.key) + assertEquals(Origin.SOT, written.origin) + assertEquals(fetchesAfterHydration, backend.fetchCount) + assertEquals(emptyList(), users.pending(key)) + assertNull(users.runtime()) + } finally { + writtenAwaiter.cancelAndJoin() + } + + cancelAndIgnoreRemainingEvents() + } + } finally { + try { + hydrationWrittenAwaiter.cancelAndJoin() + } finally { + users.close() + } + } + } + + @Test + fun hostileMutator_isContained_andHealthyMutationKeepsLiveStreamWorking() = runTest { + val projectionFailure = IllegalStateException("hostile projection") + lateinit var hostile: MutatorRef + lateinit var append: MutatorRef + val registry = + mutatorRegistry { + hostile = + mutator( + id = "hostile", + version = 1, + codec = FixtureUnitArgsCodec, + stales = noStales(), + ) { _, _ -> throw projectionFailure } + append = + mutator( + id = "append", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { base, suffix -> + MutationPresence.Present( + ((base as? MutationPresence.Present)?.value).orEmpty() + suffix, + ) + } + } + val backend = FakeBackend() + val key = MutationsTestKey("hostile") + val users = + mutationStore( + registry = registry, + server = backend, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + ) { + fetcher { backend.load(it) } + } + + try { + assertEquals("base", users.get(key)) + + users.stream(key).test { + awaitValue("base") + + users.poisoned.test { + val hostileId = users.mutate(key, hostile, Unit) + val poisoned = awaitPoisoned(hostileId) + assertEquals(hostile.id, poisoned.mutatorId) + assertSame(projectionFailure, poisoned.failure) + + val healthyId = users.mutate(key, append, "+healthy") + assertTrue(healthyId.isNotBlank()) + val healthy = awaitOverlayValue("base+healthy") + assertEquals(Origin.OVERLAY, healthy.origin) + cancelAndIgnoreRemainingEvents() + } + + cancelAndIgnoreRemainingEvents() + } + } finally { + users.close() + } + } +} + +private suspend fun ReceiveTurbine>.awaitValue( + expected: String, +): StoreResult.Data { + while (true) { + val data = awaitData() + if (data.value == expected) return data + } +} + +private suspend fun ReceiveTurbine>.awaitOverlayValue( + expected: String, +): StoreResult.Data { + while (true) { + val data = awaitData() + if (data.value == expected && data.origin == Origin.OVERLAY) return data + } +} + +private suspend fun ReceiveTurbine>.awaitConfirmedWithoutOldBase( + expected: String, + oldBase: String, +): StoreResult.Data { + while (true) { + val data = awaitData() + assertNotEquals(oldBase, data.value, "old base re-emitted after acknowledgement") + if ( + data.value == expected && + (data.origin == Origin.SOT || data.origin == Origin.MEMORY) + ) { + return data + } + } +} + +private suspend fun ReceiveTurbine.awaitPoisoned( + expectedMutationId: String, +): PoisonedIntent { + while (true) { + val poisoned = awaitItem() + if (poisoned.mutationId == expectedMutationId) return poisoned + } +} + +// Turbine's 3s default would nest inside the 25s shadow. Raising the Turbine deadline above +// the shadow makes runTest the only effective timeout. +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds // strictly > TEST_TIMEOUT: the shadow must fire first + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } diff --git a/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationsWiringSpikeTest.kt b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationsWiringSpikeTest.kt new file mode 100644 index 000000000..d87e5b162 --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationsWiringSpikeTest.kt @@ -0,0 +1,86 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, +) + +package org.mobilenativefoundation.store6.mutations + +import app.cash.turbine.test +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.Origin +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.time.Duration.Companion.seconds + +class MutationsWiringSpikeTest { + /** + * Successor to `lastOverlayRegistrationWins`. + * + * That spike proved core's last-overlay-registration-wins through core's public overlay + * door. No such door exists on [MutationStoreBuilder], so the engine overlay installed by + * the factory is always the sole and last registration — displacement is superseded by + * compile-time absence. `MutationApiSurfaceTest` proves the ABI-level absence; this spike + * proves the engine projection layer stays live through a fully-configured builder. + */ + @Test + fun engineOverlay_isSoleProjectionLayer_noBuilderDoorCanDisplaceIt() = runTest { + lateinit var append: MutatorRef + val registry = + mutatorRegistry { + append = + mutator( + id = "append", + version = 1, + codec = FixtureStringArgsCodec, + stales = noStales(), + ) { base, suffix -> + MutationPresence.Present( + ((base as? MutationPresence.Present)?.value).orEmpty() + suffix, + ) + } + } + val backend = FakeBackend() + val key = MutationsTestKey("sole-overlay") + val users = + mutationStore( + registry = registry, + server = backend, + keyResolver = MutationsTestKeyResolver, + valueCodecVersion = 1, + valueCodec = FixtureStringArgsCodec, + ) { + fetcher { backend.load(it) } + maxIdleKeys(8) + } + + try { + users.stream(key).test { + assertEquals("base", awaitData().value) + + users.mutate(key, append, "+pending") + var projected = awaitData() + while (projected.value != "base+pending") { + projected = awaitData() + } + assertEquals(Origin.OVERLAY, projected.origin) + assertFalse(projected.isStale) + cancelAndIgnoreRemainingEvents() + } + } finally { + users.close() + } + } +} + +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } diff --git a/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutatorSugarTest.kt b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutatorSugarTest.kt new file mode 100644 index 000000000..1c4387e92 --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutatorSugarTest.kt @@ -0,0 +1,255 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.mutations + +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNotSame +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +class MutatorSugarTest { + // The generic registration owns apply, decline, and delete outcomes distinctly. + @Test + fun genericMutator_appliesDeclinesAndDeletesDistinctly() = runTest { + lateinit var generic: MutatorRef + val registry = + mutatorRegistry { + generic = + mutator( + id = "generic", + version = 1, + codec = SugarRecordingCodec(), + stales = { _, _ -> emptyStaleSet() }, + ) { base, args -> + when { + args == "decline" -> null + args == "delete" -> MutationPresence.Absent + base is MutationPresence.Present -> + MutationPresence.Present(base.value + args) + else -> MutationPresence.Present(args) + } + } + } + val registration = registry.registrations.getValue(generic.id) + + val applied = registration.project(MutationPresence.Present("base"), "-applied") + assertEquals("base-applied", assertIs>(applied).value) + assertNull(registration.project(MutationPresence.Present("base"), "decline")) + assertSame( + MutationPresence.Absent, + assertNotNull(registration.project(MutationPresence.Present("base"), "delete")), + ) + } + + @Test + fun update_desugarsToGenericProjector() = runTest { + lateinit var rename: MutatorRef + val registry = + mutatorRegistry { + rename = + update( + id = "rename", + version = 2, + codec = SugarRecordingCodec(), + stales = { _, _ -> emptyStaleSet() }, + ) { base, suffix -> base + suffix } + } + val registration = registry.registrations.getValue(rename.id) + + // The sugar registered an ordinary durable mutator under the generic projector shape. + assertEquals("rename", registration.id) + assertEquals(2, registration.argsVersion) + val projected = registration.project(MutationPresence.Present("base"), "-renamed") + assertEquals("base-renamed", assertIs>(projected).value) + } + + @Test + fun updateOverAbsent_declinesWithoutAttempt() = runTest { + lateinit var rename: MutatorRef + val registry = + mutatorRegistry { + rename = + update( + id = "rename", + version = 1, + codec = SugarRecordingCodec(), + stales = { _, _ -> emptyStaleSet() }, + ) { base, suffix -> base + suffix } + } + val registration = registry.registrations.getValue(rename.id) + + // Null is the decline signal: the declined head never becomes an attempt and never + // means deletion. Engine-level no-attempt scheduling is exercised by the drain tests. + assertNull(registration.project(MutationPresence.Absent, "-renamed")) + } + + @Test + fun create_ignoresConfirmedBase() = runTest { + lateinit var draft: MutatorRef + val registry = + mutatorRegistry { + draft = + create( + id = "draft", + version = 1, + codec = SugarRecordingCodec(), + stales = { _, _ -> emptyStaleSet() }, + ) { title -> "created:$title" } + } + val registration = registry.registrations.getValue(draft.id) + + val overPresent = registration.project(MutationPresence.Present("resident"), "note") + val overAbsent = registration.project(MutationPresence.Absent, "note") + + assertEquals("created:note", assertIs>(overPresent).value) + assertEquals("created:note", assertIs>(overAbsent).value) + } + + @Test + fun delete_usesFixedUnitCodecAndProducesDrainableAbsent() = runTest { + lateinit var remove: MutatorRef + val registry = + mutatorRegistry { + remove = delete("remove") { _, _ -> emptyStaleSet() } + } + val registration = registry.registrations.getValue(remove.id) + + // Delete always applies Absent: a non-null projection result is drainable, never a + // decline, over both present and absent confirmed bases. + assertSame( + MutationPresence.Absent, + assertNotNull(registration.project(MutationPresence.Present("resident"), Unit)), + ) + assertSame( + MutationPresence.Absent, + assertNotNull(registration.project(MutationPresence.Absent, Unit)), + ) + // The registration owns the module's fixed Unit codec at version 1. + assertEquals(1, registration.argsVersion) + assertTrue(registration.encodeArgs(Unit).isEmpty()) + } + + @Test + fun upsert_receivesExplicitPresence() = runTest { + val receivedBases = mutableListOf>() + lateinit var put: MutatorRef + val registry = + mutatorRegistry { + put = + upsert( + id = "put", + version = 1, + codec = SugarRecordingCodec(), + stales = { _, _ -> emptyStaleSet() }, + ) { base, value -> + receivedBases += base + MutationPresence.Present(value) + } + } + val registration = registry.registrations.getValue(put.id) + + val overAbsent = registration.project(MutationPresence.Absent, "first") + val residentBase = MutationPresence.Present("resident") + val overPresent = registration.project(residentBase, "second") + + // The projector received the explicit presence values, not nullable V. + assertEquals(2, receivedBases.size) + assertSame(MutationPresence.Absent, receivedBases[0]) + assertSame>(residentBase, receivedBases[1]) + // Upsert cannot decline: both results are presences. + assertEquals("first", assertIs>(overAbsent).value) + assertEquals("second", assertIs>(overPresent).value) + } + + @Test + fun argsCodec_receivesRegisteredVersionAndDefensiveCopy() = runTest { + val codec = SugarRecordingCodec() + lateinit var append: MutatorRef + val registry = + mutatorRegistry { + append = + mutator( + id = "append", + version = 3, + codec = codec, + stales = { _, _ -> emptyStaleSet() }, + ) { _, args -> MutationPresence.Present(args) } + } + val registration = registry.registrations.getValue(append.id) + + // Encode boundary: retained bytes are a copy of the codec's returned array. + val stored = registration.encodeArgs("payload") + val returnedByCodec = assertNotNull(codec.lastEncodeResult) + assertNotSame(returnedByCodec, stored) + returnedByCodec.fill(0) + assertContentEquals("payload".encodeToByteArray(), stored) + + // Decode boundary: the codec receives the registered version and a fresh copy. + val decoded = registration.decodeArgs(registration.argsVersion, stored) + assertEquals("payload", decoded) + assertEquals(3, codec.lastDecodeVersion) + val receivedByCodec = assertNotNull(codec.lastDecodeBytes) + assertNotSame(stored, receivedByCodec) + receivedByCodec.fill(0) + assertContentEquals("payload".encodeToByteArray(), stored) + } + + @Test + fun delete_usesModuleUnitCodecV1AndZeroBytes_withoutCallerCodec() = runTest { + // Compile-level: the delete sugar's signature accepts neither a version nor a codec. + lateinit var remove: MutatorRef + val registry = + mutatorRegistry { + remove = delete("remove") { _, _ -> emptyStaleSet() } + } + val registration = registry.registrations.getValue(remove.id) + + // Fixed module-owned representation: version 1, exactly zero bytes. + assertEquals(1, registration.argsVersion) + val encoded = registration.encodeArgs(Unit) + assertContentEquals(ByteArray(0), encoded) + assertEquals(Unit, registration.decodeArgs(1, ByteArray(0))) + + // Any other durable pair is a codec violation the engine normalizes as CODEC. + assertFailsWith { + registration.decodeArgs(2, ByteArray(0)) + } + assertFailsWith { + registration.decodeArgs(1, byteArrayOf(1)) + } + } +} + +private class SugarRecordingCodec : MutationCodec { + var lastEncodeResult: ByteArray? = null + var lastDecodeVersion: Int? = null + var lastDecodeBytes: ByteArray? = null + + override fun encode(value: String): ByteArray = + value.encodeToByteArray().also { lastEncodeResult = it } + + override fun decode( + version: Int, + bytes: ByteArray, + ): String { + lastDecodeVersion = version + lastDecodeBytes = bytes + return bytes.decodeToString() + } +} + +private fun emptyStaleSet(): StaleSet = + StaleSet(keys = emptySet(), namespaces = emptySet()) + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = 25.seconds, testBody = testBody) diff --git a/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/docs/MutationDocsSnippetFixtures.kt b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/docs/MutationDocsSnippetFixtures.kt new file mode 100644 index 000000000..819c6a926 --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/docs/MutationDocsSnippetFixtures.kt @@ -0,0 +1,172 @@ +@file:OptIn(ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.mutations.docs + +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.withTimeoutOrNull +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.mutations.MutationAck +import org.mobilenativefoundation.store6.mutations.MutationCodec +import org.mobilenativefoundation.store6.mutations.MutationKeyResolver +import org.mobilenativefoundation.store6.mutations.MutationPresentAck +import org.mobilenativefoundation.store6.mutations.MutationPush +import org.mobilenativefoundation.store6.mutations.MutationServer +import org.mobilenativefoundation.store6.mutations.MutationStore +import org.mobilenativefoundation.store6.mutations.MutatorRef +import org.mobilenativefoundation.store6.mutations.StaleSet +import org.mobilenativefoundation.store6.mutations.mutatorRegistry +import kotlin.time.Duration + +private abstract class AliasCreateSnippet : MutationServer { + // docs:snippet:mutations-alias-provisional-create + lateinit var createUser: MutatorRef + + val registry = mutatorRegistry { + createUser = + create( + id = "create-user", + version = 1, + codec = newUserCodec, + stales = { _, _ -> StaleSet(keys = emptySet(), namespaces = emptySet()) }, + project = { args -> User(id = args.id, name = args.name) }, + ) + } + + suspend fun createProvisionalUser( + users: MutationStore, + provisionalKey: UserKey, + ): String = + users.mutate( + key = provisionalKey, + ref = createUser, + args = NewUser(id = provisionalKey.canonicalId(), name = "Ada"), + ) + + override suspend fun push( + request: MutationPush, + ): MutationAck { + val serverUser: User = createOnBackend(request) + return MutationPresentAck( + authoritative = serverUser, + etag = null, + canonicalKey = UserKey(serverUser.id), + ) + } + // docs:snippet:end + + protected abstract suspend fun createOnBackend(request: MutationPush): User +} + +private fun aliasResolverSnippet() { + // docs:snippet:mutations-alias-key-resolver + val keyResolver = MutationKeyResolver { identity -> + UserKey(identity.canonicalId) + } + // docs:snippet:end +} + +private suspend fun aliasStreamSnippet( + users: MutationStore, + provisionalKey: UserKey, +) { + // docs:snippet:mutations-alias-stream-rekey + // Keep this one collection across provisional and canonical delegates. + users.stream(provisionalKey).collect { result -> + when (result) { + is StoreResult.Loading -> showLoading() + is StoreResult.Data -> + renderUser( + user = result.value, + saving = result.origin == Origin.OVERLAY, + ) + is StoreResult.Revalidated -> showRevalidated(result.age) + // A resolver conversion failure is one value; this collection remains live. + is StoreResult.Error -> showReadError(result.error) + } + } + // docs:snippet:end +} + +private fun drainResolverSnippet( + knownKeys: Map, UserKey>, +) { + // docs:snippet:mutations-restart-key-resolvers + val reconstructible = + MutationKeyResolver { identity -> UserKey(identity.canonicalId) } + + val lookupBacked = + MutationKeyResolver { identity -> + knownKeys[identity.namespace to identity.canonicalId] + } + // docs:snippet:end +} + +private suspend fun mutationEventsSnippet( + mutations: MutationStore, +) { + // docs:snippet:mutations-inspection-event-collector + withTimeoutOrNull(30_000L) { + mutations.events.collect { event -> + println(event::class.simpleName ?: "MutationEvent") + } + } + // Dropped events are expected under pressure. Reconcile with durable inspection. + // docs:snippet:end +} + +private class UserKey( + private val id: String, +) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("users") + + override fun canonicalId(): String = id +} + +private data class User( + val id: String, + val name: String, +) + +private data class NewUser( + val id: String, + val name: String, +) + +private object NewUserCodec : MutationCodec { + override fun encode(value: NewUser): ByteArray = + "${value.id}\n${value.name}".encodeToByteArray() + + override fun decode( + version: Int, + bytes: ByteArray, + ): NewUser { + require(version == 1) + val fields = bytes.decodeToString().split('\n', limit = 2) + return NewUser(id = fields[0], name = fields[1]) + } +} + +private val newUserCodec: MutationCodec = NewUserCodec + +private fun showLoading() = Unit + +private fun renderUser( + user: User, + saving: Boolean, +) { + user.hashCode() + saving.hashCode() +} + +private fun showRevalidated(age: Duration) { + age.hashCode() +} + +private fun showReadError(error: StoreError) { + error.hashCode() +} diff --git a/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/docs/MutationOverviewDocsSnippet.kt b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/docs/MutationOverviewDocsSnippet.kt new file mode 100644 index 000000000..4acbe7eda --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/docs/MutationOverviewDocsSnippet.kt @@ -0,0 +1,63 @@ +@file:OptIn(ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.mutations.overviewdocs + +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.mutations.MutationCodec +import org.mobilenativefoundation.store6.mutations.MutationKeyResolver +import org.mobilenativefoundation.store6.mutations.MutationServer +import org.mobilenativefoundation.store6.mutations.MutatorRef +import org.mobilenativefoundation.store6.mutations.MutatorRegistry +import org.mobilenativefoundation.store6.mutations.mutationStore + +private suspend fun overviewSnippet( + registry: MutatorRegistry, + server: MutationServer, + userJsonCodec: MutationCodec, + api: UserApi, + key: UserKey, + renameRef: MutatorRef, +) { + // docs:snippet:mutations-overview-build-and-drain + @OptIn(ExperimentalStoreApi::class) // required: the whole module is experimental + val users = mutationStore( + registry = registry, + server = server, + // Restart-safe key recovery is compile-time required. For keys reconstructible from the + // identity pair, the resolver is one line: + keyResolver = MutationKeyResolver { identity -> UserKey(identity.canonicalId) }, + valueCodecVersion = 1, + valueCodec = userJsonCodec, + ) { + fetcher { key -> api.load(key) } + } + + users.mutate(key, renameRef, Rename("new name")) // journalled — the only write path + users.drain(key) // push pending intents and adopt each ack + // docs:snippet:end + + users.close() +} + +private class UserKey( + private val id: String, +) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("users") + + override fun canonicalId(): String = id +} + +private data class User( + val id: String, + val name: String, +) + +private data class Rename( + val name: String, +) + +private fun interface UserApi { + suspend fun load(key: UserKey): User +} diff --git a/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/docs/MutationServerContractDocsSnippets.kt b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/docs/MutationServerContractDocsSnippets.kt new file mode 100644 index 000000000..59a51070d --- /dev/null +++ b/store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/docs/MutationServerContractDocsSnippets.kt @@ -0,0 +1,20 @@ +@file:OptIn(ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.mutations.docs + +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreMeta +import org.mobilenativefoundation.store6.core.seam.StoreResults + +private fun signalConflict( + serverMeta: StoreMeta?, + message: String, + cause: Throwable?, +): Nothing { + // docs:snippet:mutations-server-conflict-signal + throw StoreResults.exception( + StoreResults.conflict(serverMeta, message), + cause, + ) + // docs:snippet:end +} diff --git a/store6-mutations/src/jvmTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationApiSurfaceTest.kt b/store6-mutations/src/jvmTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationApiSurfaceTest.kt new file mode 100644 index 000000000..f65cadc02 --- /dev/null +++ b/store6-mutations/src/jvmTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationApiSurfaceTest.kt @@ -0,0 +1,73 @@ +package org.mobilenativefoundation.store6.mutations + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * The committed KLib declaration exposes the `MutationJournalStorage` seam, but no + * overlay setter, no [org.mobilenativefoundation.store6.core.seam.StoreRuntime], no + * [org.mobilenativefoundation.store6.core.seam.StoreWriteHandle], no core `StoreBuilder` door + * through `mutationStore`, and no SQLDelight driver or `Transacter` type — the narrowed-facade + * invariant at dump level. + * The dump path arrives via the `store6.mutations.apiDumpDir` system property injected by the + * module build file; a missing property or file fails loudly rather than passing vacuously. + */ +class MutationApiSurfaceTest { + @Test + fun apiDumpContainsNoOverlaySetterRuntimeOrWriteHandleExposure() { + val dumpDir = + System.getProperty("store6.mutations.apiDumpDir") + ?: fail("store6.mutations.apiDumpDir system property is not set; check store6-mutations/build.gradle.kts jvmTest configuration.") + val dump = File(dumpDir, "store6-mutations.klib.api") + assertTrue(dump.isFile, "Committed KLib dump not found at ${dump.absolutePath}.") + val text = dump.readText() + + assertTrue( + text.contains("MutationJournalStorage"), + "Committed KLib dump is missing the ruled MutationJournalStorage seam.", + ) + + val banned = + listOf( + "StoreWriteHandle", + "StoreRuntime", + "core/StoreBuilder", + "overlay(", + "SqlDriver", + "Transacter", + ) + val violations = + banned.filter { needle -> text.contains(needle) } + assertTrue( + violations.isEmpty(), + "Committed KLib dump exposes banned seam surface: $violations", + ) + + val mutation023Banned = + listOf( + "backoff", + "policy", + "bound", + "trigger", + ) + val mutation023Violations = + mutation023Banned.filter { needle -> text.contains(needle, ignoreCase = true) } + assertTrue( + mutation023Violations.isEmpty(), + "Committed KLib dump exposes a backoff/policy/bound/trigger door: $mutation023Violations", + ) + + val dumpSha256 = + java.security.MessageDigest.getInstance("SHA-256") + .digest(dump.readBytes()) + .joinToString(separator = "") { byte -> + (byte.toInt() and 0xff).toString(radix = 16).padStart(length = 2, padChar = '0') + } + assertTrue( + dumpSha256 == "4c688dbb6d3e2b54264d3e7af7dcb05bbffdc26796ebede51607d0a6cc2f105b", + "Committed KLib dump differs from the da72d908 T0.3 baseline: $dumpSha256", + ) + } +} diff --git a/store6-mutations/src/jvmTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournalLincheckTest.kt b/store6-mutations/src/jvmTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournalLincheckTest.kt new file mode 100644 index 000000000..3b2842a23 --- /dev/null +++ b/store6-mutations/src/jvmTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournalLincheckTest.kt @@ -0,0 +1,589 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, +) + +package org.mobilenativefoundation.store6.mutations + +import kotlinx.coroutines.runBlocking +import org.jetbrains.kotlinx.lincheck.annotations.Operation +import org.jetbrains.kotlinx.lincheck.check +import org.jetbrains.kotlinx.lincheck.strategy.managed.forClasses +import org.jetbrains.kotlinx.lincheck.strategy.managed.modelchecking.ModelCheckingOptions +import org.mobilenativefoundation.store6.mutations.storage.InMemoryMutationJournalStorage +import org.mobilenativefoundation.store6.mutations.storage.MutationAckRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationAttemptRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationClientRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationEffectDisposition +import org.mobilenativefoundation.store6.mutations.storage.MutationEffectRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionPhase +import org.mobilenativefoundation.store6.mutations.storage.MutationExecutionRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationFailureRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationIntentRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationJournalTransaction +import org.mobilenativefoundation.store6.mutations.storage.MutationKeyAliasRecord +import org.mobilenativefoundation.store6.mutations.storage.MutationKeyTombstoneRecord +import kotlin.test.Test + +class MutationJournalLincheckTest { + private val storage: InMemoryMutationJournalStorage = seededLincheckStorage() + private val records = LincheckRecordFactory() + + @Operation(runOnce = true, cancellableOnSuspension = false) + suspend fun appendA(): JournalAppendResult = append(LINCHECK_SLOT_A) + + @Operation(runOnce = true, cancellableOnSuspension = false) + suspend fun appendB(): JournalAppendResult = append(LINCHECK_SLOT_B) + + @Operation(runOnce = true, cancellableOnSuspension = false) + suspend fun retireA(): JournalRetireResult = retire(LINCHECK_SLOT_A) + + @Operation(runOnce = true, cancellableOnSuspension = false) + suspend fun retireB(): JournalRetireResult = retire(LINCHECK_SLOT_B) + + @Operation(runOnce = true, cancellableOnSuspension = false) + suspend fun confirmTwo(): Long = + storage.transaction { transaction -> + val client = requireNotNull(transaction.client(LINCHECK_CLIENT_ID)) + val target = minOf(2L, client.retiredThroughSequence) + if (target > client.serverConfirmedRetiredThroughSequence) { + transaction.confirmRetiredThrough( + clientId = LINCHECK_CLIENT_ID, + requestedThroughSequence = target, + serverConfirmedThroughSequence = target, + ).serverConfirmedRetiredThroughSequence + } else { + client.serverConfirmedRetiredThroughSequence + } + } + + @Operation(cancellableOnSuspension = false) + suspend fun prune(): JournalView { + val rows = + storage.transaction { transaction -> + val confirmed = + requireNotNull(transaction.client(LINCHECK_CLIENT_ID)) + .serverConfirmedRetiredThroughSequence + transaction.prune(LINCHECK_CLIENT_ID, confirmed) + transaction.toJournalRows() + } + return JournalViewNormalizer.normalize(rows) + } + + @Operation(cancellableOnSuspension = false) + suspend fun hydrate(): JournalView { + val rows = storage.transaction(MutationJournalTransaction::toJournalRows) + return JournalViewNormalizer.normalize(rows) + } + + @Test + fun inMemoryJournalTransactions_areLinearizable() { + ModelCheckingOptions() + .iterations(100) + .threads(3) + .actorsPerThread(3) + .actorsBefore(0) + .actorsAfter(0) + .sequentialSpecification(JournalSequentialSpecification::class.java) + .addGuarantee( + forClasses(JournalViewNormalizer::class) + .allMethods() + .ignore(), + ) + .addGuarantee( + forClasses(LincheckRecordFactory::class) + .allMethods() + .ignore(), + ) + .addCustomScenario { + parallel { + thread { + actor(::appendA) + actor(::retireB) + actor(::hydrate) + } + thread { + actor(::appendB) + actor(::retireA) + actor(::hydrate) + } + thread { + actor(::confirmTwo) + actor(::prune) + actor(::hydrate) + } + } + } + .check(this::class) + } + + private suspend fun append(slot: String): JournalAppendResult = + storage.transaction { transaction -> + val client = requireNotNull(transaction.client(LINCHECK_CLIENT_ID)) + val sequence = client.lastAllocatedSequence + 1L + transaction.advanceClient( + records.client(client, lastAllocated = sequence), + ) + transaction.insertIntent( + recordVersion = 1, + clientId = LINCHECK_CLIENT_ID, + clientSequence = sequence, + mutationId = mutationIdFor(slot), + namespace = LINCHECK_NAMESPACE, + canonicalId = "slot-$slot", + mutatorId = LINCHECK_MUTATOR_ID, + mutatorVersion = 1, + argsBlob = slot.encodeToByteArray(), + idempotencyRoot = "lincheck-$slot", + createdAt = sequence, + ) + transaction.insertExecution( + records.execution(sequence, MutationExecutionPhase.UNPREPARED), + ) + JournalAppendResult(slot, sequence) + } + + private suspend fun retire(slot: String): JournalRetireResult = + storage.transaction { transaction -> + val intent = + transaction.intents(LINCHECK_CLIENT_ID).firstOrNull { row -> + row.mutationId == mutationIdFor(slot) + } ?: return@transaction JournalRetireResult(slot, null, JournalRetireOutcome.ABSENT) + val previous = + transaction.executions(LINCHECK_CLIENT_ID).single { row -> + row.clientSequence == intent.clientSequence + } + if (previous.phase == MutationExecutionPhase.RETIRED) { + return@transaction JournalRetireResult( + slot, + intent.clientSequence, + JournalRetireOutcome.RETIRED, + ) + } + require(previous.phase == MutationExecutionPhase.UNPREPARED) + val sequence = intent.clientSequence + val client = requireNotNull(transaction.client(LINCHECK_CLIENT_ID)) + transaction.insertAttempt( + records.attempt( + intent = intent, + slot = slot, + advertisedRetiredThroughSequence = client.retiredThroughSequence, + ), + ) + transaction.advanceExecution( + records.execution(sequence, MutationExecutionPhase.READY, generation = 1), + ) + transaction.advanceExecution( + records.execution(sequence, MutationExecutionPhase.INFLIGHT, generation = 1), + ) + transaction.insertAck(records.ack(sequence, slot)) + transaction.advanceExecution( + records.execution( + sequence, + MutationExecutionPhase.ACKED, + generation = 1, + attempt = 1, + lastAttemptAt = 10L + sequence, + ), + ) + transaction.advanceExecution( + records.execution( + sequence, + MutationExecutionPhase.EFFECTS_PENDING, + generation = 1, + attempt = 1, + lastAttemptAt = 10L + sequence, + ), + ) + transaction.advanceExecution( + records.execution( + sequence, + MutationExecutionPhase.RETIRED, + generation = 1, + attempt = 1, + lastAttemptAt = 10L + sequence, + retiredAt = 20L + sequence, + ), + ) + + val executions = transaction.executions(LINCHECK_CLIENT_ID) + var prefix = client.retiredThroughSequence + var executionIndex = 0 + while (executionIndex < executions.size) { + val execution = executions[executionIndex] + if ( + execution.clientSequence == prefix + 1L && + execution.phase == MutationExecutionPhase.RETIRED + ) { + prefix += 1L + } + executionIndex += 1 + } + if (prefix > client.retiredThroughSequence) { + transaction.advanceClient(records.client(client, retiredThrough = prefix)) + } + JournalRetireResult(slot, sequence, JournalRetireOutcome.RETIRED) + } +} + +data class JournalAppendResult( + val slot: String, + val sequence: Long, +) + +enum class JournalRetireOutcome { + ABSENT, + RETIRED, +} + +data class JournalRetireResult( + val slot: String, + val sequence: Long?, + val outcome: JournalRetireOutcome, +) + +data class JournalView( + val lastAllocated: Long, + val retiredThrough: Long, + val serverConfirmedThrough: Long, + val retained: List, + val orphanExecutionCount: Int, + val orphanAttemptCount: Int, + val orphanAckCount: Int, + val orphanFailureCount: Int, + val orphanEffectCount: Int, + val aliases: List, + val tombstones: List, +) + +data class RetainedIntent( + val slot: String, + val sequence: Long, + val rowId: Long, + val args: List, + val phase: MutationExecutionPhase, + val attemptGenerations: List, + val ackGenerations: List, + val effectDispositions: List, +) + +class JournalSequentialSpecification { + private var lastAllocated = 0L + private var retiredThrough = 0L + private var serverConfirmedThrough = 0L + private val slots = linkedMapOf() + + suspend fun appendA(): JournalAppendResult = append(LINCHECK_SLOT_A) + + suspend fun appendB(): JournalAppendResult = append(LINCHECK_SLOT_B) + + suspend fun retireA(): JournalRetireResult = retire(LINCHECK_SLOT_A) + + suspend fun retireB(): JournalRetireResult = retire(LINCHECK_SLOT_B) + + suspend fun confirmTwo(): Long { + serverConfirmedThrough = maxOf(serverConfirmedThrough, minOf(2L, retiredThrough)) + return serverConfirmedThrough + } + + suspend fun prune(): JournalView { + slots.values.forEach { slot -> + if (slot.sequence <= serverConfirmedThrough) slot.retained = false + } + return view() + } + + suspend fun hydrate(): JournalView = view() + + private fun append(slot: String): JournalAppendResult { + check(slot !in slots) + lastAllocated += 1L + slots[slot] = ModelSlot(sequence = lastAllocated) + return JournalAppendResult(slot, lastAllocated) + } + + private fun retire(slot: String): JournalRetireResult { + val row = slots[slot]?.takeIf(ModelSlot::retained) + ?: return JournalRetireResult(slot, null, JournalRetireOutcome.ABSENT) + row.retired = true + while (slots.values.any { candidate -> candidate.sequence == retiredThrough + 1L && candidate.retired }) { + retiredThrough += 1L + } + return JournalRetireResult(slot, row.sequence, JournalRetireOutcome.RETIRED) + } + + private fun view(): JournalView = + JournalViewNormalizer.validate( + JournalView( + lastAllocated = lastAllocated, + retiredThrough = retiredThrough, + serverConfirmedThrough = serverConfirmedThrough, + retained = + slots + .map { (slot, row) -> slot to row } + .filter { (_, row) -> row.retained } + .sortedBy { (_, row) -> row.sequence } + .map { (slot, row) -> + RetainedIntent( + slot = slot, + sequence = row.sequence, + rowId = row.sequence, + args = slot.encodeToByteArray().toList(), + phase = + if (row.retired) { + MutationExecutionPhase.RETIRED + } else { + MutationExecutionPhase.UNPREPARED + }, + attemptGenerations = if (row.retired) listOf(1) else emptyList(), + ackGenerations = if (row.retired) listOf(1) else emptyList(), + effectDispositions = emptyList(), + ) + }, + orphanExecutionCount = 0, + orphanAttemptCount = 0, + orphanAckCount = 0, + orphanFailureCount = 0, + orphanEffectCount = 0, + aliases = emptyList(), + tombstones = emptyList(), + ), + ) +} + +private class ModelSlot( + val sequence: Long, + var retired: Boolean = false, + var retained: Boolean = true, +) + +private fun seededLincheckStorage(): InMemoryMutationJournalStorage = + InMemoryMutationJournalStorage().also { storage -> + runBlocking { + storage.transaction { transaction -> + transaction.insertClient( + MutationClientRecord( + recordVersion = 1, + clientId = LINCHECK_CLIENT_ID, + lastAllocatedSequence = 0L, + retiredThroughSequence = 0L, + serverConfirmedRetiredThroughSequence = 0L, + createdAt = 0L, + ), + ) + } + } + } + +private data class JournalRows( + val client: MutationClientRecord, + val intents: List, + val executions: List, + val attempts: List, + val acknowledgements: List, + val failures: List, + val effects: List, + val aliases: List, + val tombstones: List, +) + +private fun MutationJournalTransaction.toJournalRows(): JournalRows = + JournalRows( + client = requireNotNull(client(LINCHECK_CLIENT_ID)), + intents = intents(LINCHECK_CLIENT_ID), + executions = executions(LINCHECK_CLIENT_ID), + attempts = attempts(LINCHECK_CLIENT_ID), + acknowledgements = acks(LINCHECK_CLIENT_ID), + failures = failures(LINCHECK_CLIENT_ID), + effects = effects(LINCHECK_CLIENT_ID), + aliases = aliases(), + tombstones = tombstones(), + ) + +/** Pure normalization deliberately excluded from managed scheduling; it reads detached rows only. */ +private object JournalViewNormalizer { + fun normalize(rows: JournalRows): JournalView = + with(rows) { + val sequences = intents.map { row -> row.clientSequence }.toSet() + val executionsBySequence = executions.associateBy { row -> row.clientSequence } + val attemptsBySequence = attempts.groupBy { row -> row.clientSequence } + val acksBySequence = acknowledgements.groupBy { row -> row.clientSequence } + val effectsBySequence = effects.groupBy { row -> row.clientSequence } + + validate( + JournalView( + lastAllocated = client.lastAllocatedSequence, + retiredThrough = client.retiredThroughSequence, + serverConfirmedThrough = client.serverConfirmedRetiredThroughSequence, + retained = + intents.sortedBy { row -> row.clientSequence }.map { intent -> + val slot = slotForMutationId(intent.mutationId) + RetainedIntent( + slot = slot, + sequence = intent.clientSequence, + rowId = intent.rowId, + args = intent.argsBlob.toList(), + phase = requireNotNull(executionsBySequence[intent.clientSequence]).phase, + attemptGenerations = + attemptsBySequence[intent.clientSequence].orEmpty().map { row -> row.generation }, + ackGenerations = + acksBySequence[intent.clientSequence].orEmpty().map { row -> row.generation }, + effectDispositions = + effectsBySequence[intent.clientSequence].orEmpty().map { row -> row.disposition }, + ) + }, + orphanExecutionCount = executions.count { row -> row.clientSequence !in sequences }, + orphanAttemptCount = attempts.count { row -> row.clientSequence !in sequences }, + orphanAckCount = acknowledgements.count { row -> row.clientSequence !in sequences }, + orphanFailureCount = failures.count { row -> row.clientSequence !in sequences }, + orphanEffectCount = effects.count { row -> row.clientSequence !in sequences }, + aliases = + aliases.map { row -> + "${row.sourceNamespace}:${row.sourceCanonicalId}->" + + "${row.targetNamespace}:${row.targetCanonicalId}:${row.state}" + }, + tombstones = + tombstones.map { row -> + "${row.namespace}:${row.canonicalId}:${row.createdByClientId}:" + + "${row.createdBySequence}:${row.state}" + }, + ), + ) + } + + fun validate(view: JournalView): JournalView = + view.also { + check(it.serverConfirmedThrough >= 0L) + check(it.serverConfirmedThrough <= it.retiredThrough) + check(it.retiredThrough >= 0L) + check(it.retiredThrough <= it.lastAllocated) + check(it.orphanExecutionCount == 0) + check(it.orphanAttemptCount == 0) + check(it.orphanAckCount == 0) + check(it.orphanFailureCount == 0) + check(it.orphanEffectCount == 0) + + var previousSequence = 0L + var retainedIndex = 0 + while (retainedIndex < it.retained.size) { + val row = it.retained[retainedIndex] + check(row.sequence > previousSequence) + check(row.sequence >= 1L) + check(row.sequence <= it.lastAllocated) + if (row.sequence <= it.retiredThrough) { + check(row.phase == MutationExecutionPhase.RETIRED) + } + if (row.sequence == it.retiredThrough + 1L) { + check(row.phase != MutationExecutionPhase.RETIRED) + } + if (row.phase == MutationExecutionPhase.RETIRED) { + check(row.attemptGenerations.size == 1) + check(row.attemptGenerations[0] == 1) + check(row.ackGenerations.size == 1) + check(row.ackGenerations[0] == 1) + var effectIndex = 0 + while (effectIndex < row.effectDispositions.size) { + check(row.effectDispositions[effectIndex] != MutationEffectDisposition.PENDING) + effectIndex += 1 + } + } + previousSequence = row.sequence + retainedIndex += 1 + } + } +} + +/** + * Pure immutable carrier construction excluded from managed scheduling. The storage, transaction + * lambda, mutex, reads, state transitions, and retired-prefix scan remain fully instrumented. + */ +private class LincheckRecordFactory { + fun attempt( + intent: MutationIntentRecord, + slot: String, + advertisedRetiredThroughSequence: Long, + ): MutationAttemptRecord = + MutationAttemptRecord( + clientId = LINCHECK_CLIENT_ID, + clientSequence = intent.clientSequence, + generation = 1, + effectiveNamespace = intent.namespace, + effectiveCanonicalId = intent.canonicalId, + valueCodecVersion = 1, + basePresence = MutationPresenceState.PRESENT, + baseBlob = "base-$slot".encodeToByteArray(), + minePresence = MutationPresenceState.PRESENT, + mineBlob = "mine-$slot".encodeToByteArray(), + preconditionMetaPresent = false, + preconditionWrittenAt = null, + preconditionEtag = null, + advertisedRetiredThroughSequence = advertisedRetiredThroughSequence, + generationIdempotencyKey = "lincheck-$slot-g1", + preparedAt = 5L + intent.clientSequence, + conflictMetaPresent = null, + conflictWrittenAt = null, + conflictEtag = null, + conflictReceivedAt = null, + ) + + fun ack( + sequence: Long, + slot: String, + ): MutationAckRecord = + MutationAckRecord( + clientId = LINCHECK_CLIENT_ID, + clientSequence = sequence, + generation = 1, + authoritativePresence = MutationPresenceState.PRESENT, + authoritativeBlob = "ack-$slot".encodeToByteArray(), + valueCodecVersion = 1, + etag = "etag-$slot", + canonicalTargetNamespace = null, + canonicalTargetId = null, + receivedAt = 10L + sequence, + ) + + fun execution( + sequence: Long, + phase: MutationExecutionPhase, + generation: Int = 0, + attempt: Int = 0, + lastAttemptAt: Long? = null, + retiredAt: Long? = null, + ): MutationExecutionRecord = + MutationExecutionRecord( + clientId = LINCHECK_CLIENT_ID, + clientSequence = sequence, + phase = phase, + currentGeneration = generation, + attempt = attempt, + lastAttemptAt = lastAttemptAt, + activeFailureId = null, + retiredAt = retiredAt, + ) + + fun client( + source: MutationClientRecord, + lastAllocated: Long = source.lastAllocatedSequence, + retiredThrough: Long = source.retiredThroughSequence, + ): MutationClientRecord = + MutationClientRecord( + recordVersion = source.recordVersion, + clientId = source.clientId, + lastAllocatedSequence = lastAllocated, + retiredThroughSequence = retiredThrough, + serverConfirmedRetiredThroughSequence = source.serverConfirmedRetiredThroughSequence, + createdAt = source.createdAt, + ) +} + +private fun mutationIdFor(slot: String): String = "lincheck-$slot" + +private fun slotForMutationId(mutationId: String): String = + mutationId.removePrefix("lincheck-") + +private const val LINCHECK_CLIENT_ID: String = "lincheck-client" +private const val LINCHECK_NAMESPACE: String = "lincheck" +private const val LINCHECK_MUTATOR_ID: String = "lincheck-mutator" +private const val LINCHECK_SLOT_A: String = "A" +private const val LINCHECK_SLOT_B: String = "B" diff --git a/store6-paging-androidx/api/android/store6-paging-androidx.api b/store6-paging-androidx/api/android/store6-paging-androidx.api new file mode 100644 index 000000000..b8eb0ade1 --- /dev/null +++ b/store6-paging-androidx/api/android/store6-paging-androidx.api @@ -0,0 +1,24 @@ +public final class org/mobilenativefoundation/store6/paging/PagingSourceFactoryKt { + public static final fun pagingSourceFactory (Lorg/mobilenativefoundation/store6/core/Store;Lkotlin/jvm/functions/Function1;)Landroidx/paging/InvalidatingPagingSourceFactory; +} + +public final class org/mobilenativefoundation/store6/paging/StorePagingBuilder { + public final fun freshness (Lkotlin/jvm/functions/Function1;)V + public final fun items (Lkotlin/jvm/functions/Function1;)V + public final fun itemsAfter (Lkotlin/jvm/functions/Function2;)V + public final fun itemsBefore (Lkotlin/jvm/functions/Function2;)V + public final fun nextKey (Lkotlin/jvm/functions/Function2;)V + public final fun pageKey (Lkotlin/jvm/functions/Function2;)V + public final fun prevKey (Lkotlin/jvm/functions/Function2;)V + public final fun refreshKey (Lkotlin/jvm/functions/Function1;)V +} + +public abstract class org/mobilenativefoundation/store6/paging/StoreRemoteMediator : androidx/paging/RemoteMediator { + public fun (Lorg/mobilenativefoundation/store6/core/Store;)V + public final fun load (Landroidx/paging/LoadType;Landroidx/paging/PagingState;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun nextKey (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/Object;)Ljava/lang/Object; + public abstract fun pageKey (Ljava/lang/Object;I)Lorg/mobilenativefoundation/store6/core/StoreKey; + public fun prevKey (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/Object;)Ljava/lang/Object; + public fun refreshFreshness ()Lorg/mobilenativefoundation/store6/core/Freshness; +} + diff --git a/store6-paging-androidx/api/jvm/store6-paging-androidx.api b/store6-paging-androidx/api/jvm/store6-paging-androidx.api new file mode 100644 index 000000000..b8eb0ade1 --- /dev/null +++ b/store6-paging-androidx/api/jvm/store6-paging-androidx.api @@ -0,0 +1,24 @@ +public final class org/mobilenativefoundation/store6/paging/PagingSourceFactoryKt { + public static final fun pagingSourceFactory (Lorg/mobilenativefoundation/store6/core/Store;Lkotlin/jvm/functions/Function1;)Landroidx/paging/InvalidatingPagingSourceFactory; +} + +public final class org/mobilenativefoundation/store6/paging/StorePagingBuilder { + public final fun freshness (Lkotlin/jvm/functions/Function1;)V + public final fun items (Lkotlin/jvm/functions/Function1;)V + public final fun itemsAfter (Lkotlin/jvm/functions/Function2;)V + public final fun itemsBefore (Lkotlin/jvm/functions/Function2;)V + public final fun nextKey (Lkotlin/jvm/functions/Function2;)V + public final fun pageKey (Lkotlin/jvm/functions/Function2;)V + public final fun prevKey (Lkotlin/jvm/functions/Function2;)V + public final fun refreshKey (Lkotlin/jvm/functions/Function1;)V +} + +public abstract class org/mobilenativefoundation/store6/paging/StoreRemoteMediator : androidx/paging/RemoteMediator { + public fun (Lorg/mobilenativefoundation/store6/core/Store;)V + public final fun load (Landroidx/paging/LoadType;Landroidx/paging/PagingState;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun nextKey (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/Object;)Ljava/lang/Object; + public abstract fun pageKey (Ljava/lang/Object;I)Lorg/mobilenativefoundation/store6/core/StoreKey; + public fun prevKey (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/Object;)Ljava/lang/Object; + public fun refreshFreshness ()Lorg/mobilenativefoundation/store6/core/Freshness; +} + diff --git a/store6-paging-androidx/api/store6-paging-androidx.klib.api b/store6-paging-androidx/api/store6-paging-androidx.klib.api new file mode 100644 index 000000000..cfbedb144 --- /dev/null +++ b/store6-paging-androidx/api/store6-paging-androidx.klib.api @@ -0,0 +1,30 @@ +// Klib ABI Dump +// Targets: [iosArm64, iosSimulatorArm64, js, linuxX64, macosArm64, mingwX64, tvosArm64, wasmJs, watchosArm64] +// Rendering settings: +// - Signature version: 2 +// - Show manifest properties: true +// - Show declarations: true + +// Library unique name: +abstract class <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any, #C: kotlin/Any, #D: kotlin/Any> org.mobilenativefoundation.store6.paging/StoreRemoteMediator : androidx.paging/RemoteMediator<#C, #D> { // org.mobilenativefoundation.store6.paging/StoreRemoteMediator|null[0] + constructor (org.mobilenativefoundation.store6.core/Store<#A, #B>) // org.mobilenativefoundation.store6.paging/StoreRemoteMediator.|(org.mobilenativefoundation.store6.core.Store<1:0,1:1>){}[0] + + abstract fun nextKey(#A, #B): #C? // org.mobilenativefoundation.store6.paging/StoreRemoteMediator.nextKey|nextKey(1:0;1:1){}[0] + abstract fun pageKey(#C?, kotlin/Int): #A // org.mobilenativefoundation.store6.paging/StoreRemoteMediator.pageKey|pageKey(1:2?;kotlin.Int){}[0] + final suspend fun load(androidx.paging/LoadType, androidx.paging/PagingState<#C, #D>): androidx.paging/RemoteMediator.MediatorResult // org.mobilenativefoundation.store6.paging/StoreRemoteMediator.load|load(androidx.paging.LoadType;androidx.paging.PagingState<1:2,1:3>){}[0] + open fun prevKey(#A, #B): #C? // org.mobilenativefoundation.store6.paging/StoreRemoteMediator.prevKey|prevKey(1:0;1:1){}[0] + open fun refreshFreshness(): org.mobilenativefoundation.store6.core/Freshness // org.mobilenativefoundation.store6.paging/StoreRemoteMediator.refreshFreshness|refreshFreshness(){}[0] +} + +final class <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any, #C: kotlin/Any, #D: kotlin/Any> org.mobilenativefoundation.store6.paging/StorePagingBuilder { // org.mobilenativefoundation.store6.paging/StorePagingBuilder|null[0] + final fun freshness(kotlin/Function1) // org.mobilenativefoundation.store6.paging/StorePagingBuilder.freshness|freshness(kotlin.Function1){}[0] + final fun items(kotlin/Function1<#B, kotlin.collections/List<#D>>) // org.mobilenativefoundation.store6.paging/StorePagingBuilder.items|items(kotlin.Function1<1:1,kotlin.collections.List<1:3>>){}[0] + final fun itemsAfter(kotlin/Function2<#A, #B, kotlin/Int>) // org.mobilenativefoundation.store6.paging/StorePagingBuilder.itemsAfter|itemsAfter(kotlin.Function2<1:0,1:1,kotlin.Int>){}[0] + final fun itemsBefore(kotlin/Function2<#A, #B, kotlin/Int>) // org.mobilenativefoundation.store6.paging/StorePagingBuilder.itemsBefore|itemsBefore(kotlin.Function2<1:0,1:1,kotlin.Int>){}[0] + final fun nextKey(kotlin/Function2<#A, #B, #C?>) // org.mobilenativefoundation.store6.paging/StorePagingBuilder.nextKey|nextKey(kotlin.Function2<1:0,1:1,1:2?>){}[0] + final fun pageKey(kotlin/Function2<#C?, kotlin/Int, #A>) // org.mobilenativefoundation.store6.paging/StorePagingBuilder.pageKey|pageKey(kotlin.Function2<1:2?,kotlin.Int,1:0>){}[0] + final fun prevKey(kotlin/Function2<#A, #B, #C?>) // org.mobilenativefoundation.store6.paging/StorePagingBuilder.prevKey|prevKey(kotlin.Function2<1:0,1:1,1:2?>){}[0] + final fun refreshKey(kotlin/Function1, #C?>) // org.mobilenativefoundation.store6.paging/StorePagingBuilder.refreshKey|refreshKey(kotlin.Function1,1:2?>){}[0] +} + +final fun <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any, #C: kotlin/Any, #D: kotlin/Any> (org.mobilenativefoundation.store6.core/Store<#A, #B>).org.mobilenativefoundation.store6.paging/pagingSourceFactory(kotlin/Function1, kotlin/Unit>): androidx.paging/InvalidatingPagingSourceFactory<#C, #D> // org.mobilenativefoundation.store6.paging/pagingSourceFactory|pagingSourceFactory@org.mobilenativefoundation.store6.core.Store<0:0,0:1>(kotlin.Function1,kotlin.Unit>){0§;1§;2§;3§}[0] diff --git a/store6-paging-androidx/build.gradle.kts b/store6-paging-androidx/build.gradle.kts new file mode 100644 index 000000000..f094085cc --- /dev/null +++ b/store6-paging-androidx/build.gradle.kts @@ -0,0 +1,56 @@ +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl + +plugins { + id("org.mobilenativefoundation.store.store6.multiplatform.subset") +} + +kotlin { + // EXACT paging-common-3.5.1-supported subset: the canonical 12 minus iosX64. androidx.paging + // dropped Intel targets at 3.4.0-rc01 ("to align with Jetbrains deprecation of the macosX64 + // targets", KT-78660); every other canonical store6 target is published upstream. + // androidTarget() is mandatory under the subset plugin; spellings must exactly match + // Store6MultiplatformConventionPlugin's declarations. + androidTarget() + jvm() + + iosArm64() + iosSimulatorArm64() + macosArm64() + watchosArm64() + tvosArm64() + + linuxX64() + mingwX64() + + js { nodejs() } + @OptIn(ExperimentalWasmDsl::class) + wasmJs { nodejs() } // copy the exact @OptIn(ExperimentalWasmDsl) spelling from + // Store6MultiplatformConventionPlugin.kt — the annotation package moved + // across Kotlin releases; the full plugin's spelling is the compiling one. + + sourceSets { + val commonMain by getting { + dependencies { + api(projects.store6Core) + // androidx paging types (PagingSource, LoadParams, LoadResult, PagingState, + // InvalidatingPagingSourceFactory, RemoteMediator) appear in public signatures. + api(libs.androidx.paging.common) + } + } + val commonTest by getting { + dependencies { + implementation(projects.store6Testing) + // Test-only: MutationStore is exercised as a Store implementation; the production adapter + // has no dependency on the mutations module. + implementation(projects.store6Mutations) + implementation(libs.androidx.paging.testing) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.turbine) + } + } + } +} + +android { + namespace = "org.mobilenativefoundation.store6.paging" +} diff --git a/store6-paging-androidx/gradle.properties b/store6-paging-androidx/gradle.properties new file mode 100644 index 000000000..11a0ce99f --- /dev/null +++ b/store6-paging-androidx/gradle.properties @@ -0,0 +1,3 @@ +VERSION_NAME=6.0.0-SNAPSHOT +POM_NAME=store6-paging-androidx +POM_ARTIFACT_ID=store6-paging-androidx diff --git a/store6-paging-androidx/sample/build.gradle.kts b/store6-paging-androidx/sample/build.gradle.kts new file mode 100644 index 000000000..c03c19ceb --- /dev/null +++ b/store6-paging-androidx/sample/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + id("org.jetbrains.kotlin.jvm") + application +} + +kotlin { jvmToolchain(11) } + +dependencies { + implementation(projects.store6PagingAndroidx) + implementation(projects.store6Mutations) +} + +application { mainClass.set("org.mobilenativefoundation.store6.paging.sample.MainKt") } diff --git a/store6-paging-androidx/sample/src/main/kotlin/org/mobilenativefoundation/store6/paging/sample/Main.kt b/store6-paging-androidx/sample/src/main/kotlin/org/mobilenativefoundation/store6/paging/sample/Main.kt new file mode 100644 index 000000000..55e7553f3 --- /dev/null +++ b/store6-paging-androidx/sample/src/main/kotlin/org/mobilenativefoundation/store6/paging/sample/Main.kt @@ -0,0 +1,600 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, +) + +package org.mobilenativefoundation.store6.paging.sample + +import androidx.paging.ItemSnapshotList +import androidx.paging.LoadType +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.asItemSnapshotListFlow +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeout +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreMeta +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.core.seam.Fetcher +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import org.mobilenativefoundation.store6.core.seam.KeyStatus +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import org.mobilenativefoundation.store6.core.store +import org.mobilenativefoundation.store6.mutations.MutationAck +import org.mobilenativefoundation.store6.mutations.MutationCodec +import org.mobilenativefoundation.store6.mutations.MutationKeyIdentity +import org.mobilenativefoundation.store6.mutations.MutationKeyResolver +import org.mobilenativefoundation.store6.mutations.MutationPresentAck +import org.mobilenativefoundation.store6.mutations.MutationPush +import org.mobilenativefoundation.store6.mutations.MutationRetirement +import org.mobilenativefoundation.store6.mutations.MutationRetirementAck +import org.mobilenativefoundation.store6.mutations.MutationServer +import org.mobilenativefoundation.store6.mutations.MutationStore +import org.mobilenativefoundation.store6.mutations.MutatorRef +import org.mobilenativefoundation.store6.mutations.StaleSet +import org.mobilenativefoundation.store6.mutations.mutationStore +import org.mobilenativefoundation.store6.mutations.mutatorRegistry +import org.mobilenativefoundation.store6.paging.pagingSourceFactory +import kotlin.time.Duration.Companion.days + +public fun main(): Unit = + runBlocking { + withTimeout(SAMPLE_TIMEOUT_MILLIS) { + runSample() + } + } + +private suspend fun CoroutineScope.runSample() { + val page0Key = PageKey(index = 0, limit = PAGE_SIZE) + val page1Key = PageKey(index = 1, limit = PAGE_SIZE) + val page2Key = PageKey(index = 2, limit = PAGE_SIZE) + val initialPage0 = page("page-0-v1", next = 1) + val initialPage1 = page("page-1-v1", next = 2) + val regeneratedPage0 = page("page-0-v2", next = 1) + val namespacePage0 = page("page-0-v3", next = 1) + val namespacePage1 = page("page-1-v2", next = 2) + val olderPage2 = page("page-2-old", next = null) + val newerPage2 = page("page-2-new", next = null) + val sourceOfTruth = SampleSourceOfTruth() + val bookkeeper = SampleBookkeeper() + val backend = PageBackend().apply { + seed(page0Key, initialPage0) + seed(page1Key, initialPage1) + seed(page2Key, newerPage2) + } + val store = + store { + fetcher(backend) + persistence(sourceOfTruth) + bookkeeper(bookkeeper) + } + val mutationBackend = MutationPageBackend() + val pageMutations = PageMutations() + val mutationStore = mutationStore(pageMutations, mutationBackend) + val snapshotJob = SupervisorJob(coroutineContext[Job]) + val snapshotScope = CoroutineScope(coroutineContext + snapshotJob) + + try { + val pager = store.pager(appendFreshness = Freshness.MaxAge(1.days)) + val snapshots = pager.snapshots(snapshotScope) + + val coldPage0 = snapshots.awaitItems(initialPage0.items) + pager.append() + val coldWindow = snapshots.awaitItems(initialPage0.items + initialPage1.items) + check(coldPage0 == initialPage0.items) + check(coldWindow == initialPage0.items + initialPage1.items) + check(backend.totalFetchCount() == 2L) + println("Scene 1: cold page 0 + append page 1; items=$coldWindow; fetches=2") + + backend.seed(page0Key, regeneratedPage0) + val page0FetchesBeforeInvalidation = backend.fetchCount(page0Key) + store.invalidate(page0Key) + val regeneratedWindow = snapshots.awaitFirstPage(regeneratedPage0.items) + check(backend.fetchCount(page0Key) == page0FetchesBeforeInvalidation + 1L) + check(backend.totalFetchCount() == 3L) + println( + "Scene 2: invalidate page 0 -> regenerated; " + + "items=${regeneratedWindow.take(PAGE_SIZE)}; fetches=${backend.totalFetchCount()}", + ) + + sourceOfTruth.write(page2Key, olderPage2) + bookkeeper.recordSuccess(page2Key, SampleStoreMeta(System.currentTimeMillis())) + check(backend.fetchCount(page2Key) == 0L) + backend.seed(page0Key, namespacePage0) + backend.seed(page1Key, namespacePage1) + store.invalidateNamespace(PAGES_NAMESPACE) + pager.refresh() + snapshots.awaitFirstPage(namespacePage0.items) + pager.append() + snapshots.awaitItems(namespacePage0.items + namespacePage1.items) + + var olderPage2Observed = false + val page2Probe = + snapshotScope.launch(start = CoroutineStart.UNDISPATCHED) { + snapshots.collect { snapshot -> + if (snapshot.items.any { item -> item.startsWith("page-2-old-") }) { + olderPage2Observed = true + } + } + } + pager.append() + val namespaceWindow = + snapshots.awaitItems(namespacePage0.items + namespacePage1.items + newerPage2.items) + page2Probe.cancelAndJoin() + check(!olderPage2Observed) + check(backend.fetchCount(page2Key) == 1L) + println( + "Scene 3: namespace watermark refreshed never-fetched page 2; " + + "page0=${namespaceWindow.take(PAGE_SIZE)}; " + + "page2=${namespaceWindow.takeLast(PAGE_SIZE)}; page2-fetches=1", + ) + + val mutationKey = PageKey(index = 0, limit = PAGE_SIZE) + val original = page("mutation-original", next = null) + val optimistic = page("mutation-optimistic", next = null) + val authoritative = page("mutation-authoritative", next = null) + mutationBackend.seed(mutationKey, original) + mutationBackend.acknowledge(mutationKey, authoritative) + val mutationPager = mutationStore.pager(appendFreshness = Freshness.CachedOrFetch) + val mutationSnapshots = mutationPager.snapshots(snapshotScope) + check(mutationSnapshots.awaitItems(original.items) == original.items) + check(mutationBackend.fetchCount(mutationKey) == 1L) + + val probe = OriginProbe(original, optimistic, authoritative) + val probeJob = + snapshotScope.launch(start = CoroutineStart.UNDISPATCHED) { + probe.collect(mutationStore, mutationKey) + } + probe.initial.await() + mutationStore.mutate(mutationKey, pageMutations.replacePage, optimistic) + val overlay = probe.overlay.await() + check(overlay.origin == Origin.OVERLAY) + check(mutationSnapshots.awaitItems(optimistic.items) == optimistic.items) + val fetchesBeforeAcknowledgement = mutationBackend.fetchCount(mutationKey) + + mutationStore.drain(mutationKey) + val adopted = probe.adopted.await() + check(adopted.origin == Origin.SOT) + check(mutationSnapshots.awaitItems(authoritative.items) == authoritative.items) + check(mutationStore.pending(mutationKey).isEmpty()) + val acknowledgementFetches = + mutationBackend.fetchCount(mutationKey) - fetchesBeforeAcknowledgement + check(acknowledgementFetches == 0L) + probeJob.cancelAndJoin() + println( + "Scene 4: OVERLAY frame observed; SOT adoption observed; " + + "ack-path fetches=$acknowledgementFetches", + ) + } finally { + snapshotJob.cancelAndJoin() + mutationStore.close() + store.close() + } +} + +private fun Store.pager(appendFreshness: Freshness): Pager = + Pager( + PagingConfig( + pageSize = PAGE_SIZE, + initialLoadSize = PAGE_SIZE, + enablePlaceholders = false, + ), + pagingSourceFactory = + pagingSourceFactory { + pageKey { paginationKey, loadSize -> + PageKey(index = paginationKey ?: 0, limit = loadSize) + } + items { value -> value.items } + nextKey { _, value -> value.next } + prevKey { _, value -> value.prev } + freshness { loadType -> + when (loadType) { + LoadType.APPEND -> appendFreshness + LoadType.PREPEND, + LoadType.REFRESH, + -> Freshness.CachedOrFetch + } + } + }, + ) + +private fun Pager.snapshots( + scope: CoroutineScope, +): SharedFlow> = + flow + .asItemSnapshotListFlow() + .shareIn(scope, started = SharingStarted.Eagerly, replay = 1) + +private suspend fun SharedFlow>.awaitItems( + expected: List, +): List = first { snapshot -> snapshot.items == expected }.items + +private suspend fun SharedFlow>.awaitFirstPage( + expected: List, +): List = first { snapshot -> snapshot.items.take(PAGE_SIZE) == expected }.items + +private data class PageKey( + val index: Int, + val limit: Int, +) : StoreKey { + override val namespace: StoreNamespace = PAGES_NAMESPACE + + override fun canonicalId(): String = "$index+$limit" +} + +private data class Page( + val items: List, + val next: Int?, + val prev: Int?, +) + +private fun page( + prefix: String, + next: Int?, + prev: Int? = null, +): Page = Page(List(PAGE_SIZE) { index -> "$prefix-$index" }, next = next, prev = prev) + +private open class PageBackend : Fetcher { + private val truth = ConcurrentHashMap() + private val fetchCounts = ConcurrentHashMap() + + fun seed( + key: PageKey, + value: Page, + ) { + truth[key] = value + } + + fun fetchCount(key: PageKey): Long = fetchCounts[key]?.get() ?: 0L + + fun totalFetchCount(): Long = fetchCounts.values.sumOf { count -> count.get() } + + override suspend fun fetch( + key: PageKey, + etag: String?, + ): FetcherResult { + fetchCounts.computeIfAbsent(key) { AtomicLong() }.incrementAndGet() + return FetcherResult.Success( + checkNotNull(truth[key]) { + "No backend page for ${key.namespace.value}/${key.canonicalId()}." + }, + ) + } +} + +private class MutationPageBackend : PageBackend(), MutationServer { + private val acknowledgements = ConcurrentHashMap() + + fun acknowledge( + key: PageKey, + value: Page, + ) { + acknowledgements[key] = value + } + + override suspend fun push(request: MutationPush): MutationAck { + val authoritative = + checkNotNull(acknowledgements[request.key]) { + "No acknowledgement for ${request.identity.namespace}/${request.identity.canonicalId}." + } + seed(request.key, authoritative) + return MutationPresentAck( + authoritative = authoritative, + etag = "sample-authoritative", + canonicalKey = null, + ) + } + + override suspend fun retire(request: MutationRetirement): MutationRetirementAck = + MutationRetirementAck(confirmedThroughSequence = request.retiredThroughSequence) +} + +private class PageMutations { + lateinit var replacePage: MutatorRef + private set + + val registry = + mutatorRegistry { + replacePage = + update( + id = "replace-page", + version = 1, + codec = PageCodec, + stales = { _, _ -> + StaleSet(keys = emptySet(), namespaces = emptySet()) + }, + project = { _, replacement -> replacement }, + ) + } +} + +private fun mutationStore( + mutations: PageMutations, + backend: MutationPageBackend, +): MutationStore = + mutationStore( + registry = mutations.registry, + server = backend, + keyResolver = PageKeyResolver, + valueCodecVersion = 1, + valueCodec = PageCodec, + ) { + fetcher(backend) + } + +private object PageKeyResolver : MutationKeyResolver { + override suspend fun resolve(identity: MutationKeyIdentity): PageKey? { + if (identity.namespace != PAGES_NAMESPACE.value) return null + val fields = identity.canonicalId.split('+') + if (fields.size != 2) return null + return PageKey( + index = fields[0].toIntOrNull() ?: return null, + limit = fields[1].toIntOrNull() ?: return null, + ) + } +} + +private object PageCodec : MutationCodec { + override fun encode(value: Page): ByteArray = + buildList { + add(value.next?.toString().orEmpty()) + add(value.prev?.toString().orEmpty()) + add(value.items.size.toString()) + addAll(value.items) + }.joinToString(CODEC_SEPARATOR).encodeToByteArray() + + override fun decode( + version: Int, + bytes: ByteArray, + ): Page { + require(version == 1) { "Sample Page values require version 1; was $version." } + val fields = bytes.decodeToString().split(CODEC_SEPARATOR) + require(fields.size >= 3) { "Sample Page values require three header fields." } + val itemCount = fields[2].toInt() + require(fields.size == itemCount + 3) { + "Sample Page declared $itemCount items but encoded ${fields.size - 3}." + } + return Page( + items = fields.drop(3), + next = fields[0].ifEmpty { null }?.toInt(), + prev = fields[1].ifEmpty { null }?.toInt(), + ) + } +} + +private class OriginProbe( + private val original: Page, + private val optimistic: Page, + private val authoritative: Page, +) { + val initial = CompletableDeferred>() + val overlay = CompletableDeferred>() + val adopted = CompletableDeferred>() + + suspend fun collect( + store: Store, + key: PageKey, + ) { + store.stream(key, Freshness.LocalOnly) + .filterIsInstance>() + .collect { data -> + when { + data.value == original -> initial.complete(data) + data.value == optimistic && data.origin == Origin.OVERLAY -> + overlay.complete(data) + overlay.isCompleted && + data.value == authoritative && + data.origin == Origin.SOT -> adopted.complete(data) + } + } + } +} + +private class SampleSourceOfTruth : SourceOfTruth { + private data class VersionedRow( + val sequence: Long, + val value: Page?, + ) + + private val sequence = AtomicLong() + private val rows = ConcurrentHashMap>() + + override fun reader(key: PageKey): Flow = row(key).map { versioned -> versioned.value } + + override suspend fun write( + key: PageKey, + value: Page, + ) { + row(key).value = VersionedRow(sequence.incrementAndGet(), value) + } + + override suspend fun delete(key: PageKey) { + row(key).value = VersionedRow(sequence.incrementAndGet(), null) + } + + override suspend fun deleteNamespace(namespace: StoreNamespace) { + rows.forEach { (key, row) -> + if (key.namespace.value == namespace.value) { + row.value = VersionedRow(sequence.incrementAndGet(), null) + } + } + } + + override suspend fun deleteAll() { + rows.values.forEach { row -> + row.value = VersionedRow(sequence.incrementAndGet(), null) + } + } + + private fun row(key: PageKey): MutableStateFlow = + rows.computeIfAbsent(key) { MutableStateFlow(VersionedRow(0L, null)) } +} + +private class SampleBookkeeper : Bookkeeper { + private data class Identity( + val namespace: String, + val canonicalId: String, + ) + + private data class Record( + val meta: StoreMeta?, + val successSequence: Long?, + val lastFailureAtEpochMillis: Long?, + val consecutiveFailures: Int, + val staleSequence: Long?, + ) + + private val lock = Mutex() + private var sequence = 0L + private val records = mutableMapOf() + private val namespaceWatermarks = mutableMapOf() + private var globalWatermark = 0L + + override suspend fun recordSuccess( + key: StoreKey, + meta: StoreMeta, + ) { + val identity = key.identity() + lock.withLock { + val previous = records[identity] + records[identity] = + Record( + meta = meta, + successSequence = nextSequence(), + lastFailureAtEpochMillis = null, + consecutiveFailures = 0, + staleSequence = previous?.staleSequence, + ) + } + } + + override suspend fun recordFailure( + key: StoreKey, + atEpochMillis: Long, + ) { + val identity = key.identity() + lock.withLock { + val previous = records[identity] + records[identity] = + Record( + meta = previous?.meta, + successSequence = previous?.successSequence, + lastFailureAtEpochMillis = atEpochMillis, + consecutiveFailures = (previous?.consecutiveFailures ?: 0) + 1, + staleSequence = previous?.staleSequence, + ) + } + } + + override suspend fun status(key: StoreKey): KeyStatus? { + val identity = key.identity() + return lock.withLock { + val record = records[identity] + val staleSequence = + maxOf( + record?.staleSequence ?: 0L, + namespaceWatermarks[identity.namespace] ?: 0L, + globalWatermark, + ) + if (record == null && staleSequence == 0L) { + null + } else { + KeyStatus( + meta = record?.meta, + lastSuccessSequence = record?.successSequence, + lastFailureAtEpochMillis = record?.lastFailureAtEpochMillis, + consecutiveFailures = record?.consecutiveFailures ?: 0, + durablyStale = staleSequence > (record?.successSequence ?: 0L), + ) + } + } + } + + override suspend fun forget(key: StoreKey) { + lock.withLock { + records.remove(key.identity()) + } + } + + override suspend fun markStale(key: StoreKey) { + val identity = key.identity() + lock.withLock { + val previous = records[identity] + records[identity] = + Record( + meta = previous?.meta, + successSequence = previous?.successSequence, + lastFailureAtEpochMillis = previous?.lastFailureAtEpochMillis, + consecutiveFailures = previous?.consecutiveFailures ?: 0, + staleSequence = nextSequence(), + ) + } + } + + override suspend fun advanceStaleWatermark(namespace: StoreNamespace) { + lock.withLock { + namespaceWatermarks[namespace.value] = nextSequence() + } + } + + override suspend fun advanceGlobalStaleWatermark() { + lock.withLock { + globalWatermark = nextSequence() + } + } + + override suspend fun forgetNamespace(namespace: StoreNamespace) { + lock.withLock { + records.keys.removeAll { identity -> identity.namespace == namespace.value } + } + } + + override suspend fun forgetAll() { + lock.withLock { + records.clear() + } + } + + private fun StoreKey.identity(): Identity = Identity(namespace.value, canonicalId()) + + private fun nextSequence(): Long { + check(sequence < Long.MAX_VALUE) { "Sample bookkeeper sequence exhausted." } + sequence += 1L + return sequence + } +} + +private class SampleStoreMeta( + override val writtenAtEpochMillis: Long, + override val etag: String? = null, +) : StoreMeta + +private const val PAGE_SIZE = 3 +private const val SAMPLE_TIMEOUT_MILLIS = 20_000L +private const val CODEC_SEPARATOR = "\u0000" +private val PAGES_NAMESPACE = StoreNamespace("sample-pages") diff --git a/store6-paging-androidx/src/androidMain/AndroidManifest.xml b/store6-paging-androidx/src/androidMain/AndroidManifest.xml new file mode 100644 index 000000000..8072ee00d --- /dev/null +++ b/store6-paging-androidx/src/androidMain/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/store6-paging-androidx/src/commonMain/kotlin/org/mobilenativefoundation/store6/paging/GenerationWatcher.kt b/store6-paging-androidx/src/commonMain/kotlin/org/mobilenativefoundation/store6/paging/GenerationWatcher.kt new file mode 100644 index 000000000..84485c342 --- /dev/null +++ b/store6-paging-androidx/src/commonMain/kotlin/org/mobilenativefoundation/store6/paging/GenerationWatcher.kt @@ -0,0 +1,222 @@ +package org.mobilenativefoundation.store6.paging + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreResult + +/** Owns the live Store collectors associated with one paging-source generation. */ +internal class GenerationWatcher( + private val store: Store, + private val invalidateGeneration: () -> Unit, +) { + internal sealed interface BaselineResult { + class Frame internal constructor( + val result: StoreResult, + internal val entry: Entry, + ) : BaselineResult + + data class Failure(val throwable: Throwable) : BaselineResult + + data object Stopped : BaselineResult + } + + private data class KeyId( + val namespace: String, + val canonicalId: String, + ) + + internal sealed interface Resolution { + data object ARMED : Resolution + + data object DISCARDED : Resolution + + data object STOPPED : Resolution + + data class Failed(val throwable: Throwable) : Resolution + } + + internal class Entry { + val baseline = CompletableDeferred>() + val armed = CompletableDeferred() + val resolution = CompletableDeferred() + var job: Job? = null + } + + private val generationJob = SupervisorJob() + private val scope = CoroutineScope(Dispatchers.Default + generationJob) + private val mutex = Mutex() + private val entries = mutableMapOf>() + private var invalidationRequested = false + + suspend fun baseline( + key: K, + freshness: Freshness, + ): BaselineResult { + val id = KeyId(key.namespace.value, key.canonicalId()) + while (generationJob.isActive) { + val (entry, ownsEntry) = + mutex.withLock { + val existing = entries[id] + if (existing != null) { + existing to false + } else { + Entry().also { entries[id] = it } to true + } + } + + if (!ownsEntry) { + when (val resolution = entry.resolution.await()) { + Resolution.ARMED -> { + requestInvalidation() + return BaselineResult.Stopped + } + Resolution.DISCARDED -> continue + Resolution.STOPPED -> return BaselineResult.Stopped + is Resolution.Failed -> return BaselineResult.Failure(resolution.throwable) + } + } + + entry.job = + scope.launch(start = CoroutineStart.UNDISPATCHED) { + var baselineObserved = false + try { + store.stream(key, freshness).collect { result -> + if (!baselineObserved) { + if (result is StoreResult.Loading) return@collect + baselineObserved = true + entry.baseline.complete(BaselineResult.Frame(result, entry)) + entry.armed.await() + } else { + when (result) { + is StoreResult.Data, + is StoreResult.Loading, + -> requestInvalidation() + is StoreResult.Error, + is StoreResult.Revalidated, + -> Unit + } + } + } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (failure: Throwable) { + if ( + !baselineObserved && + entry.baseline.complete(BaselineResult.Failure(failure)) + ) { + entry.resolution.complete(Resolution.Failed(failure)) + } else { + requestInvalidation() + } + } finally { + if (!entry.baseline.isCompleted) { + entry.baseline.complete(BaselineResult.Stopped) + } + if (!entry.resolution.isCompleted) { + entry.resolution.complete(Resolution.STOPPED) + } + } + } + + try { + return entry.baseline.await() + } catch (cancellation: CancellationException) { + withContext(NonCancellable) { abandon(id, entry) } + throw cancellation + } + } + return BaselineResult.Stopped + } + + suspend fun watch( + key: K, + baselineFrameObserved: BaselineResult.Frame, + ) { + val entry = baselineFrameObserved.entry + val id = KeyId(key.namespace.value, key.canonicalId()) + val arm = + mutex.withLock { + val ownsEntry = entries[id] === entry + if (ownsEntry && generationJob.isActive && !invalidationRequested) { + entry.resolution.complete(Resolution.ARMED) + true + } else { + if (ownsEntry) entries.remove(id) + if (!entry.resolution.isCompleted) { + entry.resolution.complete(Resolution.STOPPED) + } + false + } + } + if (arm) { + entry.armed.complete(Unit) + } else { + entry.job?.cancel() + } + } + + suspend fun discard( + key: K, + baselineFrameObserved: BaselineResult.Frame, + ) { + val entry = baselineFrameObserved.entry + val id = KeyId(key.namespace.value, key.canonicalId()) + mutex.withLock { + if (entries[id] === entry) entries.remove(id) + if (!entry.resolution.isCompleted) { + entry.resolution.complete(Resolution.DISCARDED) + } + } + entry.job?.cancel() + } + + fun cancel() { + generationJob.cancel() + } + + private suspend fun requestInvalidation() { + val shouldInvalidate = + mutex.withLock { + if (invalidationRequested || !generationJob.isActive) { + false + } else { + invalidationRequested = true + true + } + } + // Paging invalidation cancels this scope synchronously through its registered callback. + // Invoke it only after releasing the mutex so callback cleanup cannot reenter the lock. + if (shouldInvalidate) invalidateGeneration() + } + + private suspend fun abandon( + id: KeyId, + entry: Entry, + ) { + val cancelCollector = + mutex.withLock { + if (entries[id] === entry && !entry.resolution.isCompleted) { + entries.remove(id) + entry.resolution.complete(Resolution.DISCARDED) + true + } else { + false + } + } + if (cancelCollector) entry.job?.cancel() + } +} diff --git a/store6-paging-androidx/src/commonMain/kotlin/org/mobilenativefoundation/store6/paging/PagingSourceFactory.kt b/store6-paging-androidx/src/commonMain/kotlin/org/mobilenativefoundation/store6/paging/PagingSourceFactory.kt new file mode 100644 index 000000000..09c11b754 --- /dev/null +++ b/store6-paging-androidx/src/commonMain/kotlin/org/mobilenativefoundation/store6/paging/PagingSourceFactory.kt @@ -0,0 +1,34 @@ +package org.mobilenativefoundation.store6.paging + +import androidx.paging.InvalidatingPagingSourceFactory +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreKey + +/** + * Builds an androidx [InvalidatingPagingSourceFactory] over this store. + * + * Each load consumes at most one terminal outcome from [Store.stream]. After a page is ready, its + * generation keeps that same stream collection active. Any later data frame, including equal or + * stale data, and any absent-value loading transition invalidates the paging source. Revalidation + * and error frames do not. Loads never call `Store.get`, so values projected by a configured stream + * overlay remain visible. Calling `invalidate()` on the returned factory invalidates every paging + * source previously created by that factory. When the pager is no longer used, call `invalidate()` + * on the returned factory or close the store to release the generation's active Store stream + * collectors. + * + * @throws IllegalStateException if [configure] omits a required builder door + */ +@ExperimentalStoreApi +public fun Store.pagingSourceFactory( + configure: StorePagingBuilder.() -> Unit, +): InvalidatingPagingSourceFactory { + val config = StorePagingBuilder().apply(configure).build() + return InvalidatingPagingSourceFactory { + lateinit var pagingSource: StorePagingSource + val watcher = GenerationWatcher(this) { pagingSource.invalidate() } + pagingSource = StorePagingSource(this, config, watcher) + pagingSource.registerInvalidatedCallback(watcher::cancel) + pagingSource + } +} diff --git a/store6-paging-androidx/src/commonMain/kotlin/org/mobilenativefoundation/store6/paging/StorePagingBuilder.kt b/store6-paging-androidx/src/commonMain/kotlin/org/mobilenativefoundation/store6/paging/StorePagingBuilder.kt new file mode 100644 index 000000000..31bec1daf --- /dev/null +++ b/store6-paging-androidx/src/commonMain/kotlin/org/mobilenativefoundation/store6/paging/StorePagingBuilder.kt @@ -0,0 +1,102 @@ +package org.mobilenativefoundation.store6.paging + +import androidx.paging.LoadType +import androidx.paging.PagingSource +import androidx.paging.PagingState +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.StoreKey + +/** Configures how store keys and values map to androidx paging loads and pages. */ +@ExperimentalStoreApi +public class StorePagingBuilder internal constructor() { + private var pageKey: ((paginationKey: PK?, loadSize: Int) -> K)? = null + private var items: ((V) -> List)? = null + private var nextKey: ((K, V) -> PK?)? = null + private var prevKey: (K, V) -> PK? = { _, _ -> null } + private var freshness: (LoadType) -> Freshness = { Freshness.CachedOrFetch } + private var refreshKey: (PagingState) -> PK? = { state -> + state.anchorPosition?.let { anchor -> + state.closestPageToPosition(anchor)?.let { page -> page.prevKey ?: page.nextKey } + } + } + private var itemsBefore: (K, V) -> Int = { _, _ -> + PagingSource.LoadResult.Page.COUNT_UNDEFINED + } + private var itemsAfter: (K, V) -> Int = { _, _ -> + PagingSource.LoadResult.Page.COUNT_UNDEFINED + } + + /** + * Maps a pagination key and load size to the store key for that page. + * + * This door is required. A `null` pagination key identifies the initial refresh. Page + * parameters should be included in the returned key's canonical ID. + */ + public fun pageKey(block: (paginationKey: PK?, loadSize: Int) -> K) { + pageKey = block + } + + /** Extracts page items from a store value. This door is required. */ + public fun items(block: (V) -> List) { + items = block + } + + /** + * Extracts the next pagination key, or `null` at the terminal edge. This door is required. + */ + public fun nextKey(block: (K, V) -> PK?) { + nextKey = block + } + + /** Extracts the previous pagination key. The default returns `null` for forward-only paging. */ + public fun prevKey(block: (K, V) -> PK?) { + prevKey = block + } + + /** + * Selects store freshness for each paging [LoadType]. The default is + * [Freshness.CachedOrFetch] for every load type. + */ + public fun freshness(block: (LoadType) -> Freshness) { + freshness = block + } + + /** + * Selects the refresh key from the current paging state. + * + * By default, the page closest to the anchor contributes its previous key when present and + * its next key otherwise. A state without an anchor or closest page returns `null`. + */ + public fun refreshKey(block: (PagingState) -> PK?) { + refreshKey = block + } + + /** + * Computes the number of unloaded items before a loaded page. The default is + * [PagingSource.LoadResult.Page.COUNT_UNDEFINED]. + */ + public fun itemsBefore(block: (K, V) -> Int) { + itemsBefore = block + } + + /** + * Computes the number of unloaded items after a loaded page. The default is + * [PagingSource.LoadResult.Page.COUNT_UNDEFINED]. + */ + public fun itemsAfter(block: (K, V) -> Int) { + itemsAfter = block + } + + internal fun build(): StorePagingConfig = + StorePagingConfig( + pageKey = checkNotNull(pageKey) { "pageKey must be configured." }, + items = checkNotNull(items) { "items must be configured." }, + nextKey = checkNotNull(nextKey) { "nextKey must be configured." }, + prevKey = prevKey, + freshness = freshness, + refreshKey = refreshKey, + itemsBefore = itemsBefore, + itemsAfter = itemsAfter, + ) +} diff --git a/store6-paging-androidx/src/commonMain/kotlin/org/mobilenativefoundation/store6/paging/StorePagingConfig.kt b/store6-paging-androidx/src/commonMain/kotlin/org/mobilenativefoundation/store6/paging/StorePagingConfig.kt new file mode 100644 index 000000000..a4693b35e --- /dev/null +++ b/store6-paging-androidx/src/commonMain/kotlin/org/mobilenativefoundation/store6/paging/StorePagingConfig.kt @@ -0,0 +1,17 @@ +package org.mobilenativefoundation.store6.paging + +import androidx.paging.LoadType +import androidx.paging.PagingState +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.StoreKey + +internal class StorePagingConfig( + val pageKey: (paginationKey: PK?, loadSize: Int) -> K, + val items: (V) -> List, + val nextKey: (K, V) -> PK?, + val prevKey: (K, V) -> PK?, + val freshness: (LoadType) -> Freshness, + val refreshKey: (PagingState) -> PK?, + val itemsBefore: (K, V) -> Int, + val itemsAfter: (K, V) -> Int, +) diff --git a/store6-paging-androidx/src/commonMain/kotlin/org/mobilenativefoundation/store6/paging/StorePagingSource.kt b/store6-paging-androidx/src/commonMain/kotlin/org/mobilenativefoundation/store6/paging/StorePagingSource.kt new file mode 100644 index 000000000..fb28622ae --- /dev/null +++ b/store6-paging-androidx/src/commonMain/kotlin/org/mobilenativefoundation/store6/paging/StorePagingSource.kt @@ -0,0 +1,119 @@ +package org.mobilenativefoundation.store6.paging + +import androidx.paging.LoadType +import androidx.paging.PagingSource +import androidx.paging.PagingState +import kotlinx.coroutines.flow.first +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.seam.StoreResults + +@OptIn(ExperimentalStoreApi::class) +internal class StorePagingSource( + private val store: Store, + private val config: StorePagingConfig, + private val generationWatcher: GenerationWatcher, +) : PagingSource() { + override val jumpingSupported: Boolean = false + + override fun getRefreshKey(state: PagingState): PK? = config.refreshKey(state) + + override suspend fun load(params: LoadParams): LoadResult { + if (invalid) return LoadResult.Invalid() + + val loadType = + when (params) { + is LoadParams.Refresh -> LoadType.REFRESH + is LoadParams.Append -> LoadType.APPEND + is LoadParams.Prepend -> LoadType.PREPEND + } + val key = config.pageKey(params.key, params.loadSize) + val baseline = + when (val result = generationWatcher.baseline(key, config.freshness(loadType))) { + is GenerationWatcher.BaselineResult.Frame -> result + is GenerationWatcher.BaselineResult.Failure -> { + return if (invalid) { + LoadResult.Invalid() + } else { + LoadResult.Error(result.throwable) + } + } + GenerationWatcher.BaselineResult.Stopped -> return LoadResult.Invalid() + } + + if (invalid) { + generationWatcher.discard(key, baseline) + return LoadResult.Invalid() + } + + val loadResult = + try { + when (val terminal = baseline.result) { + is StoreResult.Data -> terminal.toPage(key) + is StoreResult.Error -> terminal.toLoadError() + is StoreResult.Revalidated -> residentAfterRevalidation(key) + is StoreResult.Loading -> + error("Loading was selected as a terminal store result.") + } + } catch (failure: Throwable) { + generationWatcher.discard(key, baseline) + throw failure + } + + if (invalid || loadResult !is LoadResult.Page) { + generationWatcher.discard(key, baseline) + return if (invalid) LoadResult.Invalid() else loadResult + } + + generationWatcher.watch(key, baseline) + return if (invalid) LoadResult.Invalid() else loadResult + } + + private suspend fun residentAfterRevalidation(key: K): LoadResult { + val resident = + store.stream(key, Freshness.LocalOnly) + .first { result -> + result is StoreResult.Data || result is StoreResult.Error + } + + if (invalid) return LoadResult.Invalid() + + val loadResult = + when (resident) { + is StoreResult.Data -> resident.toPage(key) + is StoreResult.Error -> resident.toLoadError() + is StoreResult.Loading, + is StoreResult.Revalidated, + -> error("LocalOnly produced a non-terminal resident result.") + } + + return if (invalid) LoadResult.Invalid() else loadResult + } + + private fun StoreResult.Data.toPage(key: K): LoadResult.Page = + LoadResult.Page( + data = config.items(value), + prevKey = config.prevKey(key, value), + nextKey = config.nextKey(key, value), + itemsBefore = config.itemsBefore(key, value), + itemsAfter = config.itemsAfter(key, value), + ) + + private fun StoreResult.Error.toLoadError(): LoadResult.Error = + LoadResult.Error(StoreResults.exception(error, error.underlyingCause())) +} + +private fun StoreError.underlyingCause(): Throwable? = + when (this) { + is StoreError.Fetch -> cause + is StoreError.Persistence -> cause + is StoreError.Conversion -> cause + is StoreError.FreshnessUnsatisfiable, + is StoreError.Conflict, + is StoreError.Missing, + -> null + } diff --git a/store6-paging-androidx/src/commonMain/kotlin/org/mobilenativefoundation/store6/paging/StoreRemoteMediator.kt b/store6-paging-androidx/src/commonMain/kotlin/org/mobilenativefoundation/store6/paging/StoreRemoteMediator.kt new file mode 100644 index 000000000..b3619b793 --- /dev/null +++ b/store6-paging-androidx/src/commonMain/kotlin/org/mobilenativefoundation/store6/paging/StoreRemoteMediator.kt @@ -0,0 +1,90 @@ +package org.mobilenativefoundation.store6.paging + +import androidx.paging.ExperimentalPagingApi +import androidx.paging.LoadType +import androidx.paging.PagingState +import androidx.paging.RemoteMediator +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreException +import org.mobilenativefoundation.store6.core.StoreKey + +/** + * Drives store freshness from Paging boundary signals. + * + * Refresh loads invalidate the initial page key before reading it. Append and prepend loads read + * the boundary page under [Freshness.CachedOrFetch]. A `null` directional key ends pagination + * without reading the store. Refresh invalidates only the mapped initial page key. Call + * [Store.invalidateNamespace] before triggering a Paging refresh when the whole query must be + * invalidated. Typed [StoreException] failures are returned as [MediatorResult.Error]. + */ +@ExperimentalStoreApi +@ExperimentalPagingApi +public abstract class StoreRemoteMediator( + private val store: Store, +) : RemoteMediator() { + /** + * Maps a pagination key and Paging load size to the store key for that page. A `null` + * pagination key identifies the initial refresh. Refresh receives + * `PagingConfig.initialLoadSize`; append and prepend receive `PagingConfig.pageSize`. + */ + public abstract fun pageKey( + paginationKey: PK?, + loadSize: Int, + ): K + + /** Extracts the next pagination key, or `null` at the terminal edge. */ + public abstract fun nextKey( + key: K, + value: V, + ): PK? + + /** Extracts the previous pagination key. The default returns `null` for forward-only paging. */ + public open fun prevKey( + key: K, + value: V, + ): PK? = null + + /** Selects freshness for refresh loads. The default is [Freshness.MustBeFresh]. */ + public open fun refreshFreshness(): Freshness = Freshness.MustBeFresh + + final override suspend fun load( + loadType: LoadType, + state: PagingState, + ): MediatorResult { + return try { + when (loadType) { + LoadType.REFRESH -> { + val key = pageKey( + paginationKey = null, + loadSize = state.config.initialLoadSize, + ) + store.invalidate(key) + val value = store.get(key, refreshFreshness()) + MediatorResult.Success(endOfPaginationReached = nextKey(key, value) == null) + } + + LoadType.APPEND -> { + val paginationKey = + state.pages.lastOrNull()?.nextKey + ?: return MediatorResult.Success(endOfPaginationReached = true) + val key = pageKey(paginationKey, state.config.pageSize) + val value = store.get(key, Freshness.CachedOrFetch) + MediatorResult.Success(endOfPaginationReached = nextKey(key, value) == null) + } + + LoadType.PREPEND -> { + val paginationKey = + state.pages.firstOrNull()?.prevKey + ?: return MediatorResult.Success(endOfPaginationReached = true) + val key = pageKey(paginationKey, state.config.pageSize) + val value = store.get(key, Freshness.CachedOrFetch) + MediatorResult.Success(endOfPaginationReached = prevKey(key, value) == null) + } + } + } catch (exception: StoreException) { + MediatorResult.Error(exception) + } + } +} diff --git a/store6-paging-androidx/src/commonTest/kotlin/org/mobilenativefoundation/store6/paging/PagingInvalidationTest.kt b/store6-paging-androidx/src/commonTest/kotlin/org/mobilenativefoundation/store6/paging/PagingInvalidationTest.kt new file mode 100644 index 000000000..352d48a9a --- /dev/null +++ b/store6-paging-androidx/src/commonTest/kotlin/org/mobilenativefoundation/store6/paging/PagingInvalidationTest.kt @@ -0,0 +1,576 @@ +@file:OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) + +package org.mobilenativefoundation.store6.paging + +import androidx.paging.PagingSource +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import org.mobilenativefoundation.store6.testing.TestStoreResults +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertSame +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.seconds + +class PagingInvalidationTest { + @Test + fun invalidateKey_drivesGenerationInvalidation() = runTest { + val key = PageKey("query", cursor = null, limit = 3) + val fetcher = + ScriptedPageFetcher().apply { + enqueue( + key, + FetcherResult.Success(Page(listOf("v1"), next = null, prev = null)), + FetcherResult.Success(Page(listOf("v2"), next = null, prev = null)), + ) + } + val store = TrackingPageStore(pageStore(fetcher)) + val factory = store.maxAgePagingFactory() + + try { + val generationA = factory() + val invalidated = generationA.invalidationProbe() + + assertEquals(listOf("v1"), generationA.refresh().data) + assertEquals(1, store.activeCollectors) + + store.invalidate(key) + invalidated.awaitCount(1) + + val generationB = factory() + assertEquals(listOf("v2"), generationB.refresh().data) + assertFalse(generationB.invalid) + assertEquals(2, fetcher.callCount(key)) + } finally { + factory.invalidate() + store.awaitActiveCollectors(0) + store.close() + } + } + + @Test + fun namespaceWatermark_coversNeverFetchedPages() = runTest { + val page0 = PageKey("query", cursor = null, limit = 1) + val page1 = PageKey("query", cursor = 1, limit = 1) + val fetcher = + ScriptedPageFetcher().apply { + enqueue( + page0, + FetcherResult.Success(Page(listOf("page-0"), next = 1, prev = null)), + FetcherResult.Success(Page(listOf("page-0-refetched"), next = 1, prev = null)), + ) + enqueue( + page1, + FetcherResult.Success(Page(listOf("page-1"), next = null, prev = 0)), + ) + } + val store = TrackingPageStore(pageStore(fetcher)) + val factory = store.maxAgePagingFactory() + + try { + val generationA = factory() + val invalidated = generationA.invalidationProbe() + + assertEquals(listOf("page-0"), generationA.refresh(loadSize = 1).data) + assertEquals(0, fetcher.callCount(page1)) + + store.invalidateNamespace(page0.namespace) + invalidated.awaitCount(1) + + val generationB = factory() + val neverFetchedPage = generationB.append(key = 1, loadSize = 1) + assertEquals(listOf("page-1"), neverFetchedPage.data) + assertFalse(generationB.invalid) + assertEquals(1, fetcher.callCount(page1)) + } finally { + factory.invalidate() + store.awaitActiveCollectors(0) + store.close() + } + } + + @Test + fun clear_absentTransition_invalidates() = runTest { + val key = PageKey("query", cursor = null, limit = 3) + val fetcher = + ScriptedPageFetcher().apply { + enqueue( + key, + FetcherResult.Success(Page(listOf("before-clear"), next = null, prev = null)), + FetcherResult.Success(Page(listOf("after-clear"), next = null, prev = null)), + ) + } + val store = TrackingPageStore(pageStore(fetcher)) + val factory = store.maxAgePagingFactory() + + try { + val generationA = factory() + val invalidated = generationA.invalidationProbe() + assertEquals(listOf("before-clear"), generationA.refresh().data) + + store.clear(key) + invalidated.awaitCount(1) + + val generationB = factory() + val afterClear = generationB.refresh() + assertEquals(listOf("after-clear"), afterClear.data) + assertFalse(generationB.invalid) + assertEquals(2, fetcher.callCount(key)) + } finally { + factory.invalidate() + store.awaitActiveCollectors(0) + store.close() + } + } + + @Test + fun staleResident_generationSequence_convergesWithoutLoop() = runTest { + val stale = Page(listOf("same-value"), next = null, prev = null) + val fresh = Page(listOf("same-value"), next = null, prev = null) + val store = + FramePageStore( + TestStoreResults.data( + stale, + origin = Origin.MEMORY, + isStale = true, + refreshing = true, + ), + ) + val factory = store.standardPagingFactory() + + try { + val generationA = factory() + val invalidatedA = generationA.invalidationProbe() + assertEquals(listOf("same-value"), generationA.refresh().data) + store.awaitDeliveries(1) + + store.emit(TestStoreResults.revalidated()) + store.awaitDeliveries(2) + assertEquals(0, invalidatedA.count) + + store.emit(TestStoreResults.error(TestStoreResults.fetchError("offline"))) + store.awaitDeliveries(3) + assertEquals(0, invalidatedA.count) + + store.emit(TestStoreResults.data(fresh, origin = Origin.FETCHER)) + invalidatedA.awaitCount(1) + assertEquals(1, invalidatedA.count) + + val generationB = factory() + val invalidatedB = generationB.invalidationProbe() + assertEquals(listOf("same-value"), generationB.refresh().data) + store.awaitActiveCollectors(1) + assertFalse(generationB.invalid) + + val deliveredBeforeQuietWindow = store.deliveries + store.emit(TestStoreResults.revalidated()) + store.awaitDeliveries(deliveredBeforeQuietWindow + 1) + store.emit(TestStoreResults.error(TestStoreResults.fetchError("still offline"))) + store.awaitDeliveries(deliveredBeforeQuietWindow + 2) + assertEquals(0, invalidatedB.count) + assertFalse(generationB.invalid) + } finally { + factory.invalidate() + store.awaitActiveCollectors(0) + store.close() + } + } + + @Test + fun revalidated_neverInvalidates() = runTest { + val resident = Page(listOf("resident"), next = null, prev = null) + val store = FramePageStore(TestStoreResults.data(resident)) + val factory = store.standardPagingFactory() + + try { + val generation = factory() + val invalidated = generation.invalidationProbe() + assertEquals(listOf("resident"), generation.refresh().data) + store.awaitDeliveries(1) + + store.emit(TestStoreResults.revalidated()) + store.awaitDeliveries(2) + store.emit(TestStoreResults.error(TestStoreResults.fetchError("post-revalidation"))) + store.awaitDeliveries(3) + + assertEquals(0, invalidated.count) + assertFalse(generation.invalid) + } finally { + factory.invalidate() + store.awaitActiveCollectors(0) + store.close() + } + } + + @Test + fun watcherScope_cancelledOnInvalidate_noLeak() = runTest { + val key = PageKey("query", cursor = null, limit = 3) + val fetcher = + ScriptedPageFetcher().apply { + enqueue(key, FetcherResult.Success(Page(listOf("value"), null, null))) + } + val store = TrackingPageStore(pageStore(fetcher)) + val factory = store.maxAgePagingFactory() + val generation = factory() + val invalidated = generation.invalidationProbe() + + assertEquals(listOf("value"), generation.refresh().data) + assertEquals(1, store.activeCollectors) + + generation.invalidate() + invalidated.awaitCount(1) + store.awaitCollectorCompletions(1) + assertEquals(0, store.activeCollectors) + assertEquals(1, store.collectorStarts) + assertEquals(1, store.collectorCompletions) + + store.close() + } + + @Test + fun factoryInvalidate_tearsDownWatcher_once() = runTest { + val baseline = Page(listOf("baseline"), next = null, prev = null) + val store = FramePageStore(TestStoreResults.data(baseline)) + val factory = store.standardPagingFactory() + val generation = factory() + val invalidated = generation.invalidationProbe() + + try { + assertEquals(listOf("baseline"), generation.refresh().data) + store.awaitDeliveries(1) + assertEquals(1, store.activeCollectors) + + val release = CompletableDeferred() + val operations = + listOf( + async(start = CoroutineStart.UNDISPATCHED) { + release.await() + store.emit(TestStoreResults.data(Page(listOf("trigger"), null, null))) + }, + async(start = CoroutineStart.UNDISPATCHED) { + release.await() + factory.invalidate() + }, + ) + release.complete(Unit) + operations.awaitAll() + invalidated.awaitCount(1) + store.awaitCollectorCompletions(1) + + assertEquals(1, invalidated.count) + assertEquals(0, store.activeCollectors) + assertEquals(1, store.collectorStarts) + assertEquals(1, store.collectorCompletions) + } finally { + store.close() + } + } + + @Test + fun closedRealStore_failureIsShared_withoutRetry() = runTest { + val delegate = pageStore(ScriptedPageFetcher()) + val store = ClosedStoreProbe(delegate) + val factory = store.standardPagingFactory() + val source = factory() + val params = + PagingSource.LoadParams.Refresh( + key = null, + loadSize = 3, + placeholdersEnabled = false, + ) + + try { + val first = async(start = CoroutineStart.UNDISPATCHED) { source.load(params) } + store.awaitCollectorStarts(1) + val second = async(start = CoroutineStart.UNDISPATCHED) { source.load(params) } + + delegate.close() + store.releaseFirstCollector() + + val firstError = assertIs>(first.await()) + val secondError = assertIs>(second.await()) + assertIs(firstError.throwable) + assertEquals("Store is closed.", firstError.throwable.message) + assertSame(firstError.throwable, secondError.throwable) + store.awaitCollectorCompletions(1) + assertEquals(1, store.collectorStarts) + assertEquals(1, store.collectorCompletions) + } finally { + factory.invalidate() + store.close() + } + } + + @Test + fun cancelledBaselineLoad_releasesCollector_andAllowsRetry() = runTest { + val store = FramePageStore(TestStoreResults.loading()) + val factory = store.standardPagingFactory() + val source = factory() + val load = + async(start = CoroutineStart.UNDISPATCHED) { + source.load( + PagingSource.LoadParams.Refresh( + key = null, + loadSize = 3, + placeholdersEnabled = false, + ), + ) + } + + try { + store.awaitDeliveries(1) + load.cancelAndJoin() + store.awaitCollectorCompletions(1) + assertEquals(0, store.activeCollectors) + + store.emit(TestStoreResults.data(Page(listOf("retry"), null, null))) + assertEquals(listOf("retry"), source.refresh().data) + assertEquals(2, store.collectorStarts) + } finally { + factory.invalidate() + store.awaitActiveCollectors(0) + store.close() + } + } +} + +private class InvalidationProbe { + private val counts = MutableStateFlow(0) + + val count: Int + get() = counts.value + + fun record() { + counts.update { it + 1 } + } + + suspend fun awaitCount(expected: Int) { + counts.first { it == expected } + } +} + +private fun PagingSource.invalidationProbe(): InvalidationProbe = + InvalidationProbe().also { probe -> registerInvalidatedCallback(probe::record) } + +private fun Store.maxAgePagingFactory() = + standardPagingFactory { + freshness { Freshness.MaxAge(1.days) } + } + +private suspend fun PagingSource.refresh( + loadSize: Int = 3, +): PagingSource.LoadResult.Page = + assertIs( + load( + PagingSource.LoadParams.Refresh( + key = null, + loadSize = loadSize, + placeholdersEnabled = false, + ), + ), + ) + +private suspend fun PagingSource.append( + key: Int, + loadSize: Int, +): PagingSource.LoadResult.Page = + assertIs( + load( + PagingSource.LoadParams.Append( + key = key, + loadSize = loadSize, + placeholdersEnabled = false, + ), + ), + ) + +private class FramePageStore( + initial: StoreResult, +) : Store { + private val frame = MutableStateFlow(initial) + private val active = MutableStateFlow(0) + private val delivered = MutableStateFlow(0) + private val starts = MutableStateFlow(0) + private val completions = MutableStateFlow(0) + private var closed = false + + val activeCollectors: Int + get() = active.value + + val deliveries: Int + get() = delivered.value + + val collectorStarts: Int + get() = starts.value + + val collectorCompletions: Int + get() = completions.value + + fun emit(result: StoreResult) { + ensureOpen() + frame.value = result + } + + suspend fun awaitActiveCollectors(expected: Int) { + active.first { it == expected } + } + + suspend fun awaitCollectorCompletions(expected: Int) { + completions.first { it == expected } + } + + suspend fun awaitDeliveries(expected: Int) { + delivered.first { it >= expected } + } + + override fun stream( + key: PageKey, + freshness: Freshness, + ): Flow> = + flow { + ensureOpen() + starts.update { it + 1 } + active.update { it + 1 } + try { + frame.collect { result -> + emit(result) + delivered.update { it + 1 } + } + } finally { + active.update { it - 1 } + completions.update { it + 1 } + } + } + + override suspend fun get( + key: PageKey, + freshness: Freshness, + ): Page { + ensureOpen() + return when (val current = frame.value) { + is StoreResult.Data -> current.value + is StoreResult.Error -> throw TestStoreResults.exception(current.error) + is StoreResult.Loading, + is StoreResult.Revalidated, + -> throw IllegalStateException("FramePageStore has no resident Data frame.") + } + } + + override suspend fun invalidate(key: PageKey) { + ensureOpen() + } + + override suspend fun invalidateNamespace(namespace: StoreNamespace) { + ensureOpen() + } + + override suspend fun invalidateAll() { + ensureOpen() + } + + override suspend fun clear(key: PageKey) { + ensureOpen() + } + + override suspend fun clearNamespace(namespace: StoreNamespace) { + ensureOpen() + } + + override suspend fun clearAll() { + ensureOpen() + } + + override fun close() { + closed = true + } + + private fun ensureOpen() { + if (closed) throw CancellationException("FramePageStore is closed.") + } +} + +private class ClosedStoreProbe( + private val delegate: Store, +) : Store by delegate { + private val releaseFirst = CompletableDeferred() + private val starts = MutableStateFlow(0) + private val completions = MutableStateFlow(0) + + val collectorStarts: Int + get() = starts.value + + val collectorCompletions: Int + get() = completions.value + + override fun stream( + key: PageKey, + freshness: Freshness, + ): Flow> = + flow { + val attempt = nextAttempt() + try { + if (attempt == 1) { + releaseFirst.await() + delegate.stream(key, freshness).collect { emit(it) } + } else { + emit(TestStoreResults.data(Page(listOf("unexpected retry"), null, null))) + awaitCancellation() + } + } finally { + completions.update { it + 1 } + } + } + + fun releaseFirstCollector() { + releaseFirst.complete(Unit) + } + + suspend fun awaitCollectorStarts(expected: Int) { + starts.first { it == expected } + } + + suspend fun awaitCollectorCompletions(expected: Int) { + completions.first { it == expected } + } + + private fun nextAttempt(): Int { + while (true) { + val current = starts.value + if (starts.compareAndSet(current, current + 1)) return current + 1 + } + } +} + +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } diff --git a/store6-paging-androidx/src/commonTest/kotlin/org/mobilenativefoundation/store6/paging/PagingMutationTracerTest.kt b/store6-paging-androidx/src/commonTest/kotlin/org/mobilenativefoundation/store6/paging/PagingMutationTracerTest.kt new file mode 100644 index 000000000..6a7a6ecb0 --- /dev/null +++ b/store6-paging-androidx/src/commonTest/kotlin/org/mobilenativefoundation/store6/paging/PagingMutationTracerTest.kt @@ -0,0 +1,463 @@ +@file:OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) + +package org.mobilenativefoundation.store6.paging + +import androidx.paging.InvalidatingPagingSourceFactory +import androidx.paging.PagingSource +import app.cash.turbine.ReceiveTurbine +import app.cash.turbine.test +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.seam.Fetcher +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import org.mobilenativefoundation.store6.mutations.MutationAck +import org.mobilenativefoundation.store6.mutations.MutationCodec +import org.mobilenativefoundation.store6.mutations.MutationKeyIdentity +import org.mobilenativefoundation.store6.mutations.MutationKeyResolver +import org.mobilenativefoundation.store6.mutations.MutationPresence +import org.mobilenativefoundation.store6.mutations.MutationPresentAck +import org.mobilenativefoundation.store6.mutations.MutationPush +import org.mobilenativefoundation.store6.mutations.MutationRetirement +import org.mobilenativefoundation.store6.mutations.MutationRetirementAck +import org.mobilenativefoundation.store6.mutations.MutationServer +import org.mobilenativefoundation.store6.mutations.MutationStore +import org.mobilenativefoundation.store6.mutations.MutatorRef +import org.mobilenativefoundation.store6.mutations.StaleSet +import org.mobilenativefoundation.store6.mutations.mutationStore +import org.mobilenativefoundation.store6.mutations.mutatorRegistry +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotEquals +import kotlin.time.Duration.Companion.seconds + +class PagingMutationTracerTest { + /** + * [MutationStore.stream] fences a resolved key on its latest projection stamp before + * delegating. A paging load started after [MutationStore.mutate] returns therefore cannot + * race ahead of that key's optimistic projection. + */ + @Test + fun pagerOverMutationStore_observesOverlayFrameAfterMutate_thenSotAfterAdoption() = runTest { + val key = TraceKey.Page(TRACE_QUERY, cursor = null, limit = 1) + val original = Page(listOf("original"), next = null, prev = null) + val optimistic = Page(listOf("optimistic"), next = null, prev = null) + val authoritative = Page(listOf("authoritative"), next = null, prev = null) + val mutations = TraceMutations() + val backend = TraceBackend().apply { + seed(key, original) + acknowledge(key, authoritative) + } + val store = traceStore(mutations, backend) + val factory = store.tracePagingFactory() + var draining: Deferred? = null + + try { + val generationA = factory() + val generationAInvalidated = generationA.invalidationSignal() + assertEquals(original.items, generationA.refresh().data) + assertEquals(1, backend.fetchCount(key)) + + store.stream(key, Freshness.LocalOnly).test { + val initial = awaitData() + assertEquals(original, initial.value) + + store.mutate(key, mutations.updatePage, "optimistic") + val overlay = awaitData(optimistic, Origin.OVERLAY) + assertEquals(Origin.OVERLAY, overlay.origin) + generationAInvalidated.await() + + val generationB = factory() + val generationBInvalidated = generationB.invalidationSignal() + assertEquals(optimistic.items, generationB.refresh().data) + assertEquals(1, backend.fetchCount(key)) + + draining = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.drain(key) + } + backend.pushStarted.await() + assertFalse(checkNotNull(draining).isCompleted) + backend.releasePush() + + val adopted = awaitData(authoritative, Origin.SOT) + assertEquals(Origin.SOT, adopted.origin) + checkNotNull(draining).await() + assertEquals(0, store.pending(key).size) + generationBInvalidated.await() + + val generationC = factory() + assertEquals(authoritative.items, generationC.refresh().data) + assertEquals(1, backend.fetchCount(key)) + cancelAndIgnoreRemainingEvents() + } + } finally { + factory.invalidate() + backend.releasePush() + draining?.cancel() + store.close() + draining?.join() + } + } + + @Test + fun entityKeyMutation_reachesPageViaStaleSetAdoption() = runTest { + val pageA = TraceKey.Page(TRACE_QUERY, cursor = null, limit = 1) + val pageB = TraceKey.Page(TRACE_QUERY, cursor = 1, limit = 1) + val entity = TraceKey.Entity("entity-1") + val originalA = Page(listOf("page-a-original"), next = 1, prev = null) + val freshA = Page(listOf("page-a-fresh"), next = 1, prev = null) + val firstB = Page(listOf("page-b-first"), next = null, prev = 0) + val mutations = TraceMutations() + val backend = TraceBackend().apply { + seed(pageA, originalA) + acknowledge(entity, Page(listOf("entity-authoritative"), null, null)) + } + val store = traceStore(mutations, backend) + val factory = store.tracePagingFactory() + var draining: Deferred? = null + + try { + val generationA = factory() + val generationAInvalidated = generationA.invalidationSignal() + assertEquals(originalA.items, generationA.refresh().data) + assertEquals(1, backend.fetchCount(pageA)) + assertEquals(0, backend.fetchCount(pageB)) + + backend.seed(pageA, freshA) + backend.seed(pageB, firstB) + store.mutate(entity, mutations.updateEntity, "entity-optimistic") + draining = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.drain(entity) + } + backend.pushStarted.await() + backend.releasePush() + checkNotNull(draining).await() + assertEquals(0, store.pending(entity).size) + generationAInvalidated.await() + + val generationB = factory() + assertEquals(freshA.items, generationB.refresh().data) + assertEquals(firstB.items, generationB.append(key = 1).data) + assertEquals(2, backend.fetchCount(pageA)) + assertEquals(1, backend.fetchCount(pageB)) + assertEquals(0, backend.fetchCount(entity)) + } finally { + factory.invalidate() + backend.releasePush() + draining?.cancel() + store.close() + draining?.join() + } + } + + @Test + fun entityKeyMutation_doesNotOptimisticallyRewritePageValue() = runTest { + val pageA = TraceKey.Page(TRACE_QUERY, cursor = null, limit = 1) + val entity = TraceKey.Entity("entity-1") + val original = Page(listOf("page-a-original"), next = null, prev = null) + val entityOptimistic = Page(listOf("entity-optimistic"), next = null, prev = null) + val mutations = TraceMutations() + val backend = TraceBackend().apply { + seed(pageA, original) + acknowledge(entity, Page(listOf("entity-authoritative"), null, null)) + } + val store = traceStore(mutations, backend) + val factory = store.tracePagingFactory() + + try { + val generation = factory() + assertEquals(original.items, generation.refresh().data) + assertEquals(1, backend.fetchCount(pageA)) + + store.mutate(entity, mutations.updateEntity, entityOptimistic.items.single()) + store.stream(pageA, Freshness.LocalOnly).test { + val observed = awaitData() + assertEquals(original, observed.value) + assertNotEquals(Origin.OVERLAY, observed.origin) + assertEquals(1, backend.fetchCount(pageA)) + assertEquals(0, backend.fetchCount(entity)) + assertEquals(1, store.pending(entity).size) + assertFalse(generation.invalid) + cancelAndIgnoreRemainingEvents() + } + } finally { + factory.invalidate() + backend.releasePush() + store.close() + } + } +} + +private sealed interface TraceKey : StoreKey { + data class Page( + val query: String, + val cursor: Int?, + val limit: Int, + ) : TraceKey { + override val namespace: StoreNamespace = StoreNamespace("pages:$query") + + override fun canonicalId(): String = "${cursor ?: "first"}+$limit" + } + + data class Entity( + val id: String, + ) : TraceKey { + override val namespace: StoreNamespace = ENTITIES_NAMESPACE + + override fun canonicalId(): String = id + } +} + +private class TraceMutations { + lateinit var updatePage: MutatorRef + private set + lateinit var updateEntity: MutatorRef + private set + + val registry = + mutatorRegistry { + updatePage = + update( + id = "update-page", + version = 1, + codec = TraceStringCodec, + stales = { _, _ -> StaleSet(keys = emptySet(), namespaces = emptySet()) }, + ) { page, item -> page.copy(items = listOf(item)) } + updateEntity = + upsert( + id = "update-entity", + version = 1, + codec = TraceStringCodec, + stales = { _, _ -> + StaleSet(keys = emptySet(), namespaces = setOf(PAGES_NAMESPACE)) + }, + ) { _, item -> + MutationPresence.Present(Page(listOf(item), next = null, prev = null)) + } + } +} + +private class TraceBackend : Fetcher, MutationServer { + private val truth = MutableStateFlow>(emptyMap()) + private val acknowledgements = MutableStateFlow>(emptyMap()) + private val fetchCounts = MutableStateFlow>(emptyMap()) + val pushStarted = CompletableDeferred>() + private val pushRelease = CompletableDeferred() + + fun seed( + key: TraceKey, + value: Page, + ) { + truth.update { current -> current + (key to value) } + } + + fun acknowledge( + key: TraceKey, + value: Page, + ) { + acknowledgements.update { current -> current + (key to value) } + } + + fun fetchCount(key: TraceKey): Int = fetchCounts.value[key] ?: 0 + + fun releasePush() { + pushRelease.complete(Unit) + } + + override suspend fun fetch( + key: TraceKey, + etag: String?, + ): FetcherResult { + fetchCounts.update { current -> current + (key to ((current[key] ?: 0) + 1)) } + return FetcherResult.Success( + checkNotNull(truth.value[key]) { + "No backend page for ${key.namespace.value}/${key.canonicalId()}." + }, + ) + } + + override suspend fun push(request: MutationPush): MutationAck { + pushStarted.complete(request) + pushRelease.await() + val authoritative = + checkNotNull(acknowledgements.value[request.key]) { + "No acknowledgement for ${request.identity.namespace}/${request.identity.canonicalId}." + } + truth.update { current -> current + (request.key to authoritative) } + return MutationPresentAck( + authoritative = authoritative, + etag = "trace-etag", + canonicalKey = null, + ) + } + + override suspend fun retire(request: MutationRetirement): MutationRetirementAck = + MutationRetirementAck(request.retiredThroughSequence) +} + +private object TraceKeyResolver : MutationKeyResolver { + override suspend fun resolve(identity: MutationKeyIdentity): TraceKey? = + when { + identity.namespace.startsWith(PAGES_NAMESPACE_PREFIX) -> { + val parameters = identity.canonicalId.split('+') + if (parameters.size != 2) { + null + } else { + val cursor = + when (parameters[0]) { + "first" -> null + else -> parameters[0].toIntOrNull() ?: return null + } + val limit = parameters[1].toIntOrNull() ?: return null + TraceKey.Page( + query = identity.namespace.removePrefix(PAGES_NAMESPACE_PREFIX), + cursor = cursor, + limit = limit, + ) + } + } + identity.namespace == ENTITIES_NAMESPACE.value -> TraceKey.Entity(identity.canonicalId) + else -> null + } +} + +private object TraceStringCodec : MutationCodec { + override fun encode(value: String): ByteArray = value.encodeToByteArray() + + override fun decode( + version: Int, + bytes: ByteArray, + ): String { + require(version == 1) { "Trace String values require version 1; was $version." } + return bytes.decodeToString() + } +} + +private object TracePageCodec : MutationCodec { + override fun encode(value: Page): ByteArray = + buildList { + add(value.next?.toString().orEmpty()) + add(value.prev?.toString().orEmpty()) + add(value.items.size.toString()) + addAll(value.items) + }.joinToString(separator = CODEC_SEPARATOR).encodeToByteArray() + + override fun decode( + version: Int, + bytes: ByteArray, + ): Page { + require(version == 1) { "Trace Page values require version 1; was $version." } + val fields = bytes.decodeToString().split(CODEC_SEPARATOR) + require(fields.size >= 3) { "Trace Page value requires three header fields." } + val itemCount = fields[2].toInt() + require(fields.size == itemCount + 3) { + "Trace Page declared $itemCount items but encoded ${fields.size - 3}." + } + return Page( + items = fields.drop(3), + next = fields[0].ifEmpty { null }?.toInt(), + prev = fields[1].ifEmpty { null }?.toInt(), + ) + } +} + +private fun traceStore( + mutations: TraceMutations, + backend: TraceBackend, +): MutationStore = + mutationStore( + registry = mutations.registry, + server = backend, + keyResolver = TraceKeyResolver, + valueCodecVersion = 1, + valueCodec = TracePageCodec, + ) { + fetcher(backend) + } + +private fun MutationStore.tracePagingFactory(): + InvalidatingPagingSourceFactory = + pagingSourceFactory { + pageKey { paginationKey, loadSize -> TraceKey.Page(TRACE_QUERY, paginationKey, loadSize) } + items { value -> value.items } + nextKey { _, value -> value.next } + prevKey { _, value -> value.prev } + } + +private suspend fun PagingSource.refresh(): PagingSource.LoadResult.Page = + assertIs( + load( + PagingSource.LoadParams.Refresh( + key = null, + loadSize = 1, + placeholdersEnabled = false, + ), + ), + ) + +private suspend fun PagingSource.append( + key: Int, +): PagingSource.LoadResult.Page = + assertIs( + load( + PagingSource.LoadParams.Append( + key = key, + loadSize = 1, + placeholdersEnabled = false, + ), + ), + ) + +private fun PagingSource.invalidationSignal(): CompletableDeferred = + CompletableDeferred().also { signal -> + registerInvalidatedCallback { signal.complete(Unit) } + } + +private suspend fun ReceiveTurbine>.awaitData(): StoreResult.Data { + while (true) { + val result = awaitItem() + if (result is StoreResult.Data) return result + } +} + +private suspend fun ReceiveTurbine>.awaitData( + value: Page, + origin: Origin, +): StoreResult.Data { + while (true) { + val result = awaitData() + if (result.value == value && result.origin == origin) return result + } +} + +private const val TRACE_QUERY = "trace" +private const val PAGES_NAMESPACE_PREFIX = "pages:" +private val PAGES_NAMESPACE = StoreNamespace("$PAGES_NAMESPACE_PREFIX$TRACE_QUERY") +private val ENTITIES_NAMESPACE = StoreNamespace("entities") +private const val CODEC_SEPARATOR = "\u0000" +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } diff --git a/store6-paging-androidx/src/commonTest/kotlin/org/mobilenativefoundation/store6/paging/PagingTestFixtures.kt b/store6-paging-androidx/src/commonTest/kotlin/org/mobilenativefoundation/store6/paging/PagingTestFixtures.kt new file mode 100644 index 000000000..0dd7f83f6 --- /dev/null +++ b/store6-paging-androidx/src/commonTest/kotlin/org/mobilenativefoundation/store6/paging/PagingTestFixtures.kt @@ -0,0 +1,148 @@ +@file:OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) + +package org.mobilenativefoundation.store6.paging + +import androidx.paging.InvalidatingPagingSourceFactory +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.store +import org.mobilenativefoundation.store6.core.seam.Fetcher +import org.mobilenativefoundation.store6.core.seam.FetcherResult + +internal data class PageKey( + val query: String, + val cursor: Int?, + val limit: Int, +) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("pages:$query") + + override fun canonicalId(): String = "$query+${cursor ?: "first"}+$limit" +} + +internal data class Page( + val items: List, + val next: Int?, + val prev: Int?, +) + +internal class ScriptedPageFetcher : Fetcher { + private val scripts = + MutableStateFlow FetcherResult>>>(emptyMap()) + private val recordedEtags = MutableStateFlow>>(emptyMap()) + + fun enqueue( + key: PageKey, + vararg results: FetcherResult, + ) { + results.forEach { result -> enqueueAction(key) { result } } + } + + fun enqueueAction( + key: PageKey, + result: suspend (etag: String?) -> FetcherResult, + ) { + scripts.update { current -> current + (key to (current[key].orEmpty() + result)) } + } + + fun fail( + key: PageKey, + cause: Throwable, + ) { + enqueue(key, FetcherResult.Error(cause)) + } + + fun callCount(key: PageKey): Int = recordedEtags.value[key].orEmpty().size + + fun etags(key: PageKey): List = recordedEtags.value[key].orEmpty() + + fun clearCalls() { + recordedEtags.value = emptyMap() + } + + override suspend fun fetch( + key: PageKey, + etag: String?, + ): FetcherResult { + recordedEtags.update { current -> current + (key to (current[key].orEmpty() + etag)) } + return pop(key)?.invoke(etag) + ?: FetcherResult.Error( + IllegalStateException( + "No scripted page result for ${key.namespace.value}/${key.canonicalId()}.", + ), + ) + } + + private fun pop(key: PageKey): (suspend (String?) -> FetcherResult)? { + while (true) { + val current = scripts.value + val queue = current[key].orEmpty() + val head = queue.firstOrNull() ?: return null + if (scripts.compareAndSet(current, current + (key to queue.drop(1)))) return head + } + } +} + +internal fun pageStore(fetcher: ScriptedPageFetcher): Store = + store { fetcher(fetcher) } + +internal class TrackingPageStore( + private val delegate: Store, +) : Store by delegate { + private val active = MutableStateFlow(0) + private val starts = MutableStateFlow(0) + private val completions = MutableStateFlow(0) + + val activeCollectors: Int + get() = active.value + + val collectorStarts: Int + get() = starts.value + + val collectorCompletions: Int + get() = completions.value + + override fun stream( + key: PageKey, + freshness: Freshness, + ): Flow> = + flow { + starts.update { it + 1 } + active.update { it + 1 } + try { + delegate.stream(key, freshness).collect { emit(it) } + } finally { + active.update { it - 1 } + completions.update { it + 1 } + } + } + + suspend fun awaitActiveCollectors(expected: Int) { + active.first { it == expected } + } + + suspend fun awaitCollectorCompletions(expected: Int) { + completions.first { it == expected } + } +} + +internal fun Store.standardPagingFactory( + query: String = "query", + additionalConfiguration: StorePagingBuilder.() -> Unit = {}, +): InvalidatingPagingSourceFactory = + pagingSourceFactory { + pageKey { paginationKey, loadSize -> PageKey(query, paginationKey, loadSize) } + items { value -> value.items } + nextKey { _, value -> value.next } + prevKey { _, value -> value.prev } + additionalConfiguration() + } diff --git a/store6-paging-androidx/src/commonTest/kotlin/org/mobilenativefoundation/store6/paging/StorePagingSourceTest.kt b/store6-paging-androidx/src/commonTest/kotlin/org/mobilenativefoundation/store6/paging/StorePagingSourceTest.kt new file mode 100644 index 000000000..f4f065092 --- /dev/null +++ b/store6-paging-androidx/src/commonTest/kotlin/org/mobilenativefoundation/store6/paging/StorePagingSourceTest.kt @@ -0,0 +1,507 @@ +@file:OptIn(ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.paging + +import androidx.paging.PagingConfig +import androidx.paging.PagingSource +import androidx.paging.PagingState +import androidx.paging.LoadType +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreException +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.time.Duration.Companion.seconds + +class StorePagingSourceTest { + @Test + fun refreshLoad_mapsFirstDataFrameToPage() = runTest { + val key = PageKey("query", cursor = null, limit = 3) + val value = Page(items = listOf("a", "b"), next = 2, prev = 0) + val fetcher = ScriptedPageFetcher().apply { + enqueue(key, FetcherResult.Success(value)) + } + val store = pageStore(fetcher) + + try { + val result = + store.standardPagingFactory()().load( + PagingSource.LoadParams.Refresh( + key = null, + loadSize = 3, + placeholdersEnabled = false, + ), + ) + + val page = assertIs>(result) + assertEquals(listOf("a", "b"), page.data) + assertEquals(2, page.nextKey) + assertEquals(0, page.prevKey) + assertEquals(1, fetcher.callCount(key)) + } finally { + store.close() + } + } + + @Test + fun appendLoad_usesPageKeyFromParams() = runTest { + val refreshKey = PageKey("query", cursor = null, limit = 4) + val appendKey = PageKey("query", cursor = 2, limit = 4) + val fetcher = ScriptedPageFetcher().apply { + enqueue( + appendKey, + FetcherResult.Success(Page(listOf("page-2"), next = 3, prev = 1)), + ) + } + val store = pageStore(fetcher) + + try { + val result = + store.standardPagingFactory()().load( + PagingSource.LoadParams.Append( + key = 2, + loadSize = 4, + placeholdersEnabled = false, + ), + ) + + val page = assertIs>(result) + assertEquals(listOf("page-2"), page.data) + assertEquals(1, fetcher.callCount(appendKey)) + assertEquals(0, fetcher.callCount(refreshKey)) + } finally { + store.close() + } + } + + @Test + fun prependLoad_mapsPrevKey() = runTest { + val key = PageKey("query", cursor = 2, limit = 5) + val fetcher = ScriptedPageFetcher().apply { + enqueue(key, FetcherResult.Success(Page(listOf("page-2"), next = 3, prev = 1))) + } + val store = pageStore(fetcher) + + try { + val result = + store.standardPagingFactory()().load( + PagingSource.LoadParams.Prepend( + key = 2, + loadSize = 5, + placeholdersEnabled = false, + ), + ) + + val page = assertIs>(result) + assertEquals(listOf("page-2"), page.data) + assertEquals(1, page.prevKey) + assertEquals(3, page.nextKey) + assertEquals(1, fetcher.callCount(key)) + } finally { + store.close() + } + } + + @Test + fun appendLoad_terminalEdge_nullNextKey() = runTest { + val key = PageKey("query", cursor = 3, limit = 2) + val fetcher = ScriptedPageFetcher().apply { + enqueue(key, FetcherResult.Success(Page(listOf("last"), next = null, prev = 2))) + } + val store = pageStore(fetcher) + + try { + val result = + store.standardPagingFactory()().load( + PagingSource.LoadParams.Append( + key = 3, + loadSize = 2, + placeholdersEnabled = false, + ), + ) + + val page = assertIs>(result) + assertNull(page.nextKey) + } finally { + store.close() + } + } + + @Test + fun storeError_surfacesAsLoadResultError_neverThrown() = runTest { + val key = PageKey("query", cursor = null, limit = 3) + val cause = IllegalStateException("offline") + val fetcher = ScriptedPageFetcher().apply { + fail(key, cause) + } + val store = pageStore(fetcher) + + try { + val result = + store.standardPagingFactory { + freshness { Freshness.MustBeFresh } + }().load( + PagingSource.LoadParams.Refresh( + key = null, + loadSize = 3, + placeholdersEnabled = false, + ), + ) + + val error = assertIs>(result) + val exception = assertIs(error.throwable) + assertIs(exception.error) + assertSame(cause, exception.cause) + } finally { + store.close() + } + } + + @Test + fun servedStaleError_surfacesAsError() = runTest { + val key = PageKey("query", cursor = null, limit = 3) + val resident = Page(listOf("resident"), next = 2, prev = null) + val fetcher = ScriptedPageFetcher().apply { + enqueue(key, FetcherResult.Success(resident, etag = "e1")) + } + val store = pageStore(fetcher) + + try { + store.get(key, Freshness.MustBeFresh) + store.invalidate(key) + fetcher.fail(key, IllegalStateException("offline")) + val trailingError = + async(start = CoroutineStart.UNDISPATCHED) { + store.stream(key, Freshness.CachedOrFetch) + .first { it is StoreResult.Error } as StoreResult.Error + } + + val result = + store.standardPagingFactory()().load( + PagingSource.LoadParams.Refresh( + key = null, + loadSize = 3, + placeholdersEnabled = false, + ), + ) + + val page = assertIs>(result) + assertEquals(listOf("resident"), page.data) + val streamError = trailingError.await() + assertEquals(true, streamError.servedStale) + assertIs(streamError.error) + } finally { + store.close() + } + + val missingKey = PageKey("missing", cursor = null, limit = 3) + val missingFetcher = ScriptedPageFetcher() + val missingStore = pageStore(missingFetcher) + + try { + val streamError = + missingStore.stream(missingKey, Freshness.LocalOnly) + .first { it is StoreResult.Error } as StoreResult.Error + assertEquals(false, streamError.servedStale) + assertIs(streamError.error) + + val result = + missingStore.standardPagingFactory(query = "missing") { + freshness { Freshness.LocalOnly } + }().load( + PagingSource.LoadParams.Refresh( + key = null, + loadSize = 3, + placeholdersEnabled = false, + ), + ) + + val error = assertIs>(result) + val exception = assertIs(error.throwable) + assertIs(exception.error) + assertEquals(0, missingFetcher.callCount(missingKey)) + } finally { + missingStore.close() + } + } + + @Test + fun revalidatedWithResident_servesResident() = runTest { + val key = PageKey("query", cursor = null, limit = 3) + val resident = Page(listOf("resident"), next = 2, prev = null) + val fetcher = ScriptedPageFetcher().apply { + enqueue(key, FetcherResult.Success(resident, etag = "e1")) + } + val store = pageStore(fetcher) + + try { + store.get(key, Freshness.MustBeFresh) + fetcher.enqueue(key, FetcherResult.NotModified(etag = "e1")) + + val result = + store.standardPagingFactory { + freshness { Freshness.MustBeFresh } + }().load( + PagingSource.LoadParams.Refresh( + key = null, + loadSize = 3, + placeholdersEnabled = false, + ), + ) + + val page = assertIs>(result) + assertEquals(listOf("resident"), page.data) + assertEquals(listOf(null, "e1"), fetcher.etags(key)) + } finally { + store.close() + } + } + + @Test + fun loadingFrames_areSkipped_notTerminal() = runTest { + val key = PageKey("query", cursor = null, limit = 3) + val fetcher = ScriptedPageFetcher().apply { + enqueue( + key, + FetcherResult.Success(Page(listOf("after-loading"), next = null, prev = null)), + ) + } + val store = pageStore(fetcher) + + try { + val result = + store.standardPagingFactory()().load( + PagingSource.LoadParams.Refresh( + key = null, + loadSize = 3, + placeholdersEnabled = false, + ), + ) + + val page = assertIs>(result) + assertEquals(listOf("after-loading"), page.data) + } finally { + store.close() + } + } + + @Test + fun racedInvalidation_returnsInvalid() = runTest { + val key = PageKey("query", cursor = null, limit = 3) + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + val fetcher = ScriptedPageFetcher().apply { + enqueueAction(key) { + fetchStarted.complete(Unit) + releaseFetch.await() + FetcherResult.Success(Page(listOf("superseded"), next = null, prev = null)) + } + } + val store = pageStore(fetcher) + + try { + val factory = store.standardPagingFactory() + val pagingSource = factory() + val load = + async { + pagingSource.load( + PagingSource.LoadParams.Refresh( + key = null, + loadSize = 3, + placeholdersEnabled = false, + ), + ) + } + + fetchStarted.await() + factory.invalidate() + releaseFetch.complete(Unit) + + assertIs>(load.await()) + } finally { + store.close() + } + } + + @Test + fun getRefreshKey_defaultAnchorsViaClosestPageToPosition() = runTest { + val store = pageStore(ScriptedPageFetcher()) + + try { + val state = + PagingState( + pages = + listOf( + pagingPage(data = listOf("page-1"), prevKey = null, nextKey = 2), + pagingPage(data = listOf("page-2"), prevKey = 1, nextKey = 3), + pagingPage(data = listOf("page-3"), prevKey = 2, nextKey = null), + ), + anchorPosition = 1, + config = PagingConfig(pageSize = 1), + leadingPlaceholderCount = 0, + ) + + assertEquals(1, store.standardPagingFactory()().getRefreshKey(state)) + assertEquals( + 99, + store.standardPagingFactory { + refreshKey { 99 } + }().getRefreshKey(state), + ) + } finally { + store.close() + } + } + + @Test + fun placeholderDoors_itemsBeforeAfter_flowThrough() = runTest { + val key = PageKey("query", cursor = null, limit = 3) + val fetcher = ScriptedPageFetcher().apply { + enqueue( + key, + FetcherResult.Success(Page(listOf("one", "two"), next = 2, prev = null)), + ) + } + val store = pageStore(fetcher) + + try { + val params = + PagingSource.LoadParams.Refresh( + key = null, + loadSize = 3, + placeholdersEnabled = true, + ) + val custom = + store.standardPagingFactory { + itemsBefore { _, _ -> 10 } + itemsAfter { _, _ -> 20 } + }().load(params) + val defaults = store.standardPagingFactory()().load(params) + + val customPage = assertIs>(custom) + assertEquals(10, customPage.itemsBefore) + assertEquals(20, customPage.itemsAfter) + val defaultPage = assertIs>(defaults) + assertEquals(PagingSource.LoadResult.Page.COUNT_UNDEFINED, defaultPage.itemsBefore) + assertEquals(PagingSource.LoadResult.Page.COUNT_UNDEFINED, defaultPage.itemsAfter) + } finally { + store.close() + } + } + + @Test + fun freshnessDoor_mapsLoadTypeToPolicy() = runTest { + val refreshKey = PageKey("query", cursor = null, limit = 3) + val appendKey = PageKey("query", cursor = 2, limit = 3) + val fetcher = ScriptedPageFetcher().apply { + enqueue( + appendKey, + FetcherResult.Success(Page(listOf("resident"), next = 3, prev = 1)), + ) + } + val store = pageStore(fetcher) + + try { + store.get(appendKey, Freshness.MustBeFresh) + fetcher.clearCalls() + fetcher.enqueue( + refreshKey, + FetcherResult.Success(Page(listOf("refresh"), next = 2, prev = null)), + ) + val factory = + store.standardPagingFactory { + freshness { loadType -> + if (loadType == LoadType.REFRESH) { + Freshness.MustBeFresh + } else { + Freshness.CachedOrFetch + } + } + } + + val refresh = + factory().load( + PagingSource.LoadParams.Refresh( + key = null, + loadSize = 3, + placeholdersEnabled = false, + ), + ) + val append = + factory().load( + PagingSource.LoadParams.Append( + key = 2, + loadSize = 3, + placeholdersEnabled = false, + ), + ) + + assertEquals(listOf("refresh"), assertPage(refresh).data) + assertEquals(listOf("resident"), assertPage(append).data) + assertEquals(1, fetcher.callCount(refreshKey)) + assertEquals(0, fetcher.callCount(appendKey)) + } finally { + store.close() + } + } + + @Test + fun builderMissingRequiredDoor_failsFast() = runTest { + val store = pageStore(ScriptedPageFetcher()) + + try { + assertFailsWith { + store.pagingSourceFactory { + items { it.items } + nextKey { _, value -> value.next } + } + } + assertFailsWith { + store.pagingSourceFactory { + pageKey { paginationKey, loadSize -> PageKey("query", paginationKey, loadSize) } + nextKey { _, value -> value.next } + } + } + assertFailsWith { + store.pagingSourceFactory { + pageKey { paginationKey, loadSize -> PageKey("query", paginationKey, loadSize) } + items { it.items } + } + } + } finally { + store.close() + } + } +} + +private fun pagingPage( + data: List, + prevKey: Int?, + nextKey: Int?, +): PagingSource.LoadResult.Page = + PagingSource.LoadResult.Page( + data = data, + prevKey = prevKey, + nextKey = nextKey, + ) + +private fun assertPage( + result: PagingSource.LoadResult, +): PagingSource.LoadResult.Page = assertIs(result) + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = 25.seconds, testBody = testBody) diff --git a/store6-paging-androidx/src/commonTest/kotlin/org/mobilenativefoundation/store6/paging/StoreRemoteMediatorTest.kt b/store6-paging-androidx/src/commonTest/kotlin/org/mobilenativefoundation/store6/paging/StoreRemoteMediatorTest.kt new file mode 100644 index 000000000..2f2aade0b --- /dev/null +++ b/store6-paging-androidx/src/commonTest/kotlin/org/mobilenativefoundation/store6/paging/StoreRemoteMediatorTest.kt @@ -0,0 +1,339 @@ +@file:OptIn( + ExperimentalStoreApi::class, + ExperimentalPagingApi::class, + DelicateStoreApi::class, +) + +package org.mobilenativefoundation.store6.paging + +import androidx.paging.ExperimentalPagingApi +import androidx.paging.PagingConfig +import androidx.paging.Pager +import androidx.paging.PagingSource +import androidx.paging.PagingState +import androidx.paging.LoadType +import androidx.paging.RemoteMediator +import androidx.paging.testing.asSnapshot +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreException +import org.mobilenativefoundation.store6.core.store +import org.mobilenativefoundation.store6.core.seam.Fetcher +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +class StoreRemoteMediatorTest { + @Test + fun mediatorRefresh_invalidatesThenGetsFresh() = runTest { + val config = PagingConfig(pageSize = 5) + val key = PageKey("query", cursor = null, limit = config.initialLoadSize) + val fetcher = ScriptedPageFetcher().apply { + enqueue(key, FetcherResult.Success(Page(listOf("resident"), next = 1, prev = null))) + enqueue(key, FetcherResult.Success(Page(listOf("fresh"), next = 2, prev = null))) + } + val delegate = pageStore(fetcher) + + try { + delegate.get(key, Freshness.MustBeFresh) + val store = RecordingPageStore(delegate) + val pageKeyCalls = mutableListOf>() + val mediator = pageMediator(store, pageKeyCalls) + + val result = mediator.load(LoadType.REFRESH, pagingState(pageSize = config.pageSize)) + + val success = assertIs(result) + assertFalse(success.endOfPaginationReached) + assertEquals(listOf(StoreOperation.INVALIDATE, StoreOperation.GET), store.operations) + assertEquals(listOf(key), store.invalidatedKeys) + assertEquals( + listOf>(key to Freshness.MustBeFresh), + store.gets, + ) + assertEquals( + listOf>(null to config.initialLoadSize), + pageKeyCalls, + ) + assertEquals(2, fetcher.callCount(key)) + } finally { + delegate.close() + } + } + + @Test + fun pagerRefresh_usesSameInitialLoadSizeForMediatorAndPagingSource() = runTest { + val config = PagingConfig(pageSize = 5, enablePlaceholders = false) + val initialKey = PageKey("query", cursor = null, limit = config.initialLoadSize) + val pageSizeKey = PageKey("query", cursor = null, limit = config.pageSize) + val initialPage = + Page( + items = List(config.initialLoadSize) { index -> "item-$index" }, + next = null, + prev = null, + ) + val wrongPage = + Page( + items = List(config.pageSize) { index -> "wrong-$index" }, + next = null, + prev = null, + ) + val fetcher = + RepeatingPageFetcher( + mapOf( + initialKey to initialPage, + pageSizeKey to wrongPage, + ), + ) + val delegate = store { fetcher(fetcher) } + val store = RecordingPageStore(delegate) + val factory = store.standardPagingFactory() + + try { + val items = + Pager( + config = config, + remoteMediator = pageMediator(store), + pagingSourceFactory = { factory() }, + ).flow.asSnapshot() + + assertEquals(initialPage.items, items) + assertEquals(listOf(initialKey), store.invalidatedKeys) + assertEquals( + listOf>(initialKey to Freshness.MustBeFresh), + store.gets, + ) + } finally { + factory.invalidate() + delegate.close() + } + } + + @Test + fun mediatorAppend_getsCachedOrFetch() = runTest { + val residentKey = PageKey("query", cursor = 2, limit = 4) + val coldKey = PageKey("query", cursor = 3, limit = 4) + val fetcher = ScriptedPageFetcher().apply { + enqueue( + residentKey, + FetcherResult.Success(Page(listOf("resident"), next = 3, prev = 1)), + ) + enqueue( + coldKey, + FetcherResult.Success(Page(listOf("cold"), next = null, prev = 2)), + ) + } + val delegate = pageStore(fetcher) + + try { + delegate.get(residentKey, Freshness.MustBeFresh) + fetcher.clearCalls() + val store = RecordingPageStore(delegate) + val mediator = pageMediator(store) + + val residentResult = + mediator.load( + LoadType.APPEND, + pagingState(nextKey = 2, pageSize = 4), + ) + + val residentSuccess = assertIs(residentResult) + assertFalse(residentSuccess.endOfPaginationReached) + assertEquals( + listOf>(residentKey to Freshness.CachedOrFetch), + store.gets, + ) + assertEquals(0, fetcher.callCount(residentKey)) + + store.clearRecords() + val coldResult = + mediator.load( + LoadType.APPEND, + pagingState(nextKey = 3, pageSize = 4), + ) + + val coldSuccess = assertIs(coldResult) + assertTrue(coldSuccess.endOfPaginationReached) + assertEquals( + listOf>(coldKey to Freshness.CachedOrFetch), + store.gets, + ) + assertEquals(1, fetcher.callCount(coldKey)) + } finally { + delegate.close() + } + } + + @Test + fun mediatorPrepend_defaultForwardOnly_endOfPagination() = runTest { + val delegate = pageStore(ScriptedPageFetcher()) + + try { + val store = RecordingPageStore(delegate) + val pageKeyCalls = mutableListOf>() + val mediator = pageMediator(store, pageKeyCalls) + + val result = + mediator.load( + LoadType.PREPEND, + pagingState(prevKey = null, nextKey = 2, pageSize = 6), + ) + + val success = assertIs(result) + assertTrue(success.endOfPaginationReached) + assertEquals(emptyList(), store.operations) + assertEquals(emptyList(), pageKeyCalls) + } finally { + delegate.close() + } + } + + @Test + fun mediatorEndOfPagination_fromNullNextKey() = runTest { + val delegate = pageStore(ScriptedPageFetcher()) + + try { + val store = RecordingPageStore(delegate) + val pageKeyCalls = mutableListOf>() + val mediator = pageMediator(store, pageKeyCalls) + + val result = + mediator.load( + LoadType.APPEND, + pagingState(prevKey = 1, nextKey = null, pageSize = 7), + ) + + val success = assertIs(result) + assertTrue(success.endOfPaginationReached) + assertEquals(emptyList(), store.operations) + assertEquals(emptyList(), pageKeyCalls) + } finally { + delegate.close() + } + } + + @Test + fun mediatorError_fromStoreException() = runTest { + val key = PageKey("query", cursor = 2, limit = 3) + val fetcher = ScriptedPageFetcher().apply { + fail(key, IllegalStateException("offline")) + } + val delegate = pageStore(fetcher) + + try { + val store = RecordingPageStore(delegate) + val mediator = pageMediator(store) + + val result = + mediator.load( + LoadType.APPEND, + pagingState(nextKey = 2, pageSize = 3), + ) + + val error = assertIs(result) + val exception = assertIs(error.throwable) + assertIs(exception.error) + } finally { + delegate.close() + } + } +} + +private class RepeatingPageFetcher( + private val pages: Map, +) : Fetcher { + override suspend fun fetch( + key: PageKey, + etag: String?, + ): FetcherResult = + pages[key]?.let { page -> FetcherResult.Success(page) } + ?: FetcherResult.Error( + IllegalStateException( + "No repeating page result for ${key.namespace.value}/${key.canonicalId()}.", + ), + ) +} + +private enum class StoreOperation { + INVALIDATE, + GET, +} + +private class RecordingPageStore( + private val delegate: Store, +) : Store by delegate { + val operations = mutableListOf() + val invalidatedKeys = mutableListOf() + val gets = mutableListOf>() + + override suspend fun invalidate(key: PageKey) { + operations += StoreOperation.INVALIDATE + invalidatedKeys += key + delegate.invalidate(key) + } + + override suspend fun get( + key: PageKey, + freshness: Freshness, + ): Page { + operations += StoreOperation.GET + gets += key to freshness + return delegate.get(key, freshness) + } + + fun clearRecords() { + operations.clear() + invalidatedKeys.clear() + gets.clear() + } +} + +private fun pageMediator( + store: Store, + pageKeyCalls: MutableList> = mutableListOf(), +): StoreRemoteMediator = + object : StoreRemoteMediator(store) { + override fun pageKey( + paginationKey: Int?, + loadSize: Int, + ): PageKey { + pageKeyCalls += paginationKey to loadSize + return PageKey("query", paginationKey, loadSize) + } + + override fun nextKey( + key: PageKey, + value: Page, + ): Int? = value.next + } + +private fun pagingState( + prevKey: Int? = null, + nextKey: Int? = null, + pageSize: Int, +): PagingState = + PagingState( + pages = + listOf( + PagingSource.LoadResult.Page( + data = listOf("loaded"), + prevKey = prevKey, + nextKey = nextKey, + ), + ), + anchorPosition = null, + config = PagingConfig(pageSize = pageSize), + leadingPlaceholderCount = 0, + ) + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = 25.seconds, testBody = testBody) diff --git a/store6-quickstart/build.gradle.kts b/store6-quickstart/build.gradle.kts new file mode 100644 index 000000000..8c246156d --- /dev/null +++ b/store6-quickstart/build.gradle.kts @@ -0,0 +1,10 @@ +plugins { + id("org.jetbrains.kotlin.jvm") + application +} + +kotlin { jvmToolchain(11) } + +dependencies { implementation(projects.store6Core) } + +application { mainClass.set("org.mobilenativefoundation.store6.quickstart.MainKt") } diff --git a/store6-quickstart/src/main/kotlin/org/mobilenativefoundation/store6/quickstart/Main.kt b/store6-quickstart/src/main/kotlin/org/mobilenativefoundation/store6/quickstart/Main.kt new file mode 100644 index 000000000..905d6e4f4 --- /dev/null +++ b/store6-quickstart/src/main/kotlin/org/mobilenativefoundation/store6/quickstart/Main.kt @@ -0,0 +1,67 @@ +package org.mobilenativefoundation.store6.quickstart + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.runBlocking +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.store + +/** Identifies a user by the stable identifier used by the example service. */ +private class UserKey( + /** The user identifier passed to the example service. */ + val id: String, +) : StoreKey { + /** The namespace shared by user records in the example store. */ + override val namespace: StoreNamespace = StoreNamespace("users") + + /** Returns the service identifier used to distinguish this user from other users. */ + override fun canonicalId(): String = id +} + +/** A user record returned by the example service. */ +private class User( + /** The stable identifier assigned to this user. */ + val id: String, + + /** The display name returned by the example service. */ + val name: String, +) + +/** Provides deterministic user data for the executable example. */ +private object FakeApi { + /** Returns a user after simulating an asynchronous service call. */ + suspend fun getUser(id: String): User { + delay(100) + return User(id, "User $id") + } +} + +/** + * Runs an end-to-end Store example backed by a deterministic user service. + * + * The program observes loading and fetched data for one user, then retrieves a second user + * directly and closes the store after both operations complete. + */ +public fun main(): Unit = + runBlocking { + // docs:snippet:guides-fetchers-quickstart + val users = store { + fetcher { key -> FakeApi.getUser(key.id) } + } + // docs:snippet:end + + users.stream(UserKey("1")).take(2).collect { result -> + // docs:snippet:migration-store-result-when + when (result) { + is StoreResult.Loading -> println("Loading…") + is StoreResult.Data -> println("Data(name=${result.value.name}, origin=${result.origin})") + is StoreResult.Revalidated -> println("Revalidated(age=${result.age})") + is StoreResult.Error -> println("Error(${result.error})") + } + // docs:snippet:end + } + println("get: ${users.get(UserKey("2")).name}") + users.close() + } diff --git a/store6-realtime/README.md b/store6-realtime/README.md new file mode 100644 index 000000000..b8fe8d9bf --- /dev/null +++ b/store6-realtime/README.md @@ -0,0 +1,105 @@ +# store6-realtime + +Maps application-owned realtime frames onto Store operations. The application supplies a +`Flow>` (or calls `apply` per frame). This artifact does not open a +WebSocket or SSE connection and does not choose a wire format. + +Every public entry point is `@ExperimentalStoreApi`. The Store seams it consumes +(`StoreWriteHandle`, `Store.runtime()`, `Store.invalidate*`, `Store.clear`) are freeze +candidates, not frozen — see [STABILITY.md](../STABILITY.md). + +## Install + +```kotlin +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation( + "org.mobilenativefoundation.store:store6-realtime:6.0.0-SNAPSHOT", + ) + } + } + } +} +``` + +The module uses Store6's full 12-target convention: Android, JVM, `iosArm64`, +`iosSimulatorArm64`, `iosX64`, `macosArm64`, `watchosArm64`, `tvosArm64`, JS, WasmJS, +`linuxX64`, and `mingwX64`. + +## Entry points + +- `realtimeBinding(store)` — adopting mode. Requires `store.runtime()`. `Upsert` commits + through `StoreWriteHandle.apply` then `confirmFresh`. `Unchanged` calls `confirmFresh`. +- `invalidatingRealtimeBinding(store)` — works on any `Store`, including `FakeStore` and + `MutationStore`. `Upsert` becomes `Store.invalidate(key)`. `Unchanged` is ignored. +- `RealtimeBinding.apply(message)` — one frame, serialized per binding. +- `RealtimeBinding.consume(messages)` — sequential pass until the flow completes. + +`realtimeBinding` throws `IllegalArgumentException` when `store.runtime()` is `null`: + +```text +realtimeBinding requires an engine-backed Store. store.runtime() is null for FakeStore, decorators, and MutationStore. Use invalidatingRealtimeBinding(store) when adoption is unavailable. +``` + +`RealtimeBinding` owns no `CoroutineScope` and has nothing to close. Close the bound Store +when the store itself should stop. + +## Vocabulary + +| Message | Adopting binding | Invalidating binding | +| --- | --- | --- | +| `Upsert(key, value, etag)` | `writeHandle.apply` then `confirmFresh` | `Store.invalidate(key)` | +| `Unchanged(key, etag)` | `writeHandle.confirmFresh` | no-op | +| `Changed(key)` | `writeHandle.markStale` (`Store.invalidate`) | `Store.invalidate(key)` | +| `ChangedNamespace(namespace)` | `Store.invalidateNamespace` | `Store.invalidateNamespace` | +| `ChangedAll` | `Store.invalidateAll` | `Store.invalidateAll` | +| `Deleted(key)` | `Store.clear(key)` | `Store.clear(key)` | + +`etag` is written as the new resident tag, including `null`. `confirmFresh` without a +resident value is a no-op. + +## Minimal example + +```kotlin +val users = store { + fetcher(userFetcher) +} +val pushes = realtimeBinding(users) + +pushes.consume(incoming) // Flow> from the app transport +``` + +The executable sample is `./gradlew :store6-realtime-sample:run`. + +## Transport, reconnect, and gaps + +Reconnect, backoff, and connectivity are host policy. After a gap in which frames may have +been missed, apply `ChangedAll` or `ChangedNamespace` for the affected space before resuming +per-key messages. The binding does not detect gaps. + +`consume` applies messages in arrival order and backpressures the flow while a Store +operation is in flight. Cross-key order is preserved. Failures propagate as thrown +`StoreException` or `IllegalStateException` (`Store is closed.`); already-applied messages +stay applied. + +An adopting `Upsert` pair is not atomic. Cancelling `apply` after `StoreWriteHandle.apply` +and before `confirmFresh` can leave the value committed without bookkeeping success. A fetch +already in flight is not cancelled; a fetch commit that runs after `apply` is later +source-of-truth authority. + +## MutationStore + +`MutationStore` withholds `store.runtime()` so `realtimeBinding` cannot adopt through the +write handle. Use `invalidatingRealtimeBinding(mutationStore)`. On reconnect, the host still +calls `MutationStore.drain()` / `drain(key)` to flush the mutation journal. Those are +separate passes. + +## Telemetry + +`Store.invalidate*` and `Store.clear` produce `KeyEvents` and, when telemetry is configured, +`StoreTelemetry` callbacks. `StoreWriteHandle.apply` emits `KeyEvents.Written(origin = SOT)`. +`confirmFresh` emits no events. An active stream that re-emits after either fires +`StoreTelemetry.onServe` when telemetry is configured. This artifact adds no event vocabulary +and does not decide a wire format. diff --git a/store6-realtime/api/android/store6-realtime.api b/store6-realtime/api/android/store6-realtime.api new file mode 100644 index 000000000..49d8a66d5 --- /dev/null +++ b/store6-realtime/api/android/store6-realtime.api @@ -0,0 +1,48 @@ +public final class org/mobilenativefoundation/store6/realtime/RealtimeBinding { + public final fun apply (Lorg/mobilenativefoundation/store6/realtime/RealtimeMessage;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun consume (Lkotlinx/coroutines/flow/Flow;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class org/mobilenativefoundation/store6/realtime/RealtimeBindingsKt { + public static final fun invalidatingRealtimeBinding (Lorg/mobilenativefoundation/store6/core/Store;)Lorg/mobilenativefoundation/store6/realtime/RealtimeBinding; + public static final fun realtimeBinding (Lorg/mobilenativefoundation/store6/core/Store;)Lorg/mobilenativefoundation/store6/realtime/RealtimeBinding; +} + +public abstract interface class org/mobilenativefoundation/store6/realtime/RealtimeMessage { +} + +public final class org/mobilenativefoundation/store6/realtime/RealtimeMessage$Changed : org/mobilenativefoundation/store6/realtime/RealtimeMessage { + public fun (Lorg/mobilenativefoundation/store6/core/StoreKey;)V + public final fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; +} + +public final class org/mobilenativefoundation/store6/realtime/RealtimeMessage$ChangedAll : org/mobilenativefoundation/store6/realtime/RealtimeMessage { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/realtime/RealtimeMessage$ChangedAll; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/realtime/RealtimeMessage$ChangedNamespace : org/mobilenativefoundation/store6/realtime/RealtimeMessage { + public fun (Lorg/mobilenativefoundation/store6/core/StoreNamespace;)V + public final fun getNamespace ()Lorg/mobilenativefoundation/store6/core/StoreNamespace; +} + +public final class org/mobilenativefoundation/store6/realtime/RealtimeMessage$Deleted : org/mobilenativefoundation/store6/realtime/RealtimeMessage { + public fun (Lorg/mobilenativefoundation/store6/core/StoreKey;)V + public final fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; +} + +public final class org/mobilenativefoundation/store6/realtime/RealtimeMessage$Unchanged : org/mobilenativefoundation/store6/realtime/RealtimeMessage { + public fun (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/String;)V + public final fun getEtag ()Ljava/lang/String; + public final fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; +} + +public final class org/mobilenativefoundation/store6/realtime/RealtimeMessage$Upsert : org/mobilenativefoundation/store6/realtime/RealtimeMessage { + public fun (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/Object;Ljava/lang/String;)V + public final fun getEtag ()Ljava/lang/String; + public final fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; + public final fun getValue ()Ljava/lang/Object; +} + diff --git a/store6-realtime/api/jvm/store6-realtime.api b/store6-realtime/api/jvm/store6-realtime.api new file mode 100644 index 000000000..49d8a66d5 --- /dev/null +++ b/store6-realtime/api/jvm/store6-realtime.api @@ -0,0 +1,48 @@ +public final class org/mobilenativefoundation/store6/realtime/RealtimeBinding { + public final fun apply (Lorg/mobilenativefoundation/store6/realtime/RealtimeMessage;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun consume (Lkotlinx/coroutines/flow/Flow;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class org/mobilenativefoundation/store6/realtime/RealtimeBindingsKt { + public static final fun invalidatingRealtimeBinding (Lorg/mobilenativefoundation/store6/core/Store;)Lorg/mobilenativefoundation/store6/realtime/RealtimeBinding; + public static final fun realtimeBinding (Lorg/mobilenativefoundation/store6/core/Store;)Lorg/mobilenativefoundation/store6/realtime/RealtimeBinding; +} + +public abstract interface class org/mobilenativefoundation/store6/realtime/RealtimeMessage { +} + +public final class org/mobilenativefoundation/store6/realtime/RealtimeMessage$Changed : org/mobilenativefoundation/store6/realtime/RealtimeMessage { + public fun (Lorg/mobilenativefoundation/store6/core/StoreKey;)V + public final fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; +} + +public final class org/mobilenativefoundation/store6/realtime/RealtimeMessage$ChangedAll : org/mobilenativefoundation/store6/realtime/RealtimeMessage { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/realtime/RealtimeMessage$ChangedAll; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/realtime/RealtimeMessage$ChangedNamespace : org/mobilenativefoundation/store6/realtime/RealtimeMessage { + public fun (Lorg/mobilenativefoundation/store6/core/StoreNamespace;)V + public final fun getNamespace ()Lorg/mobilenativefoundation/store6/core/StoreNamespace; +} + +public final class org/mobilenativefoundation/store6/realtime/RealtimeMessage$Deleted : org/mobilenativefoundation/store6/realtime/RealtimeMessage { + public fun (Lorg/mobilenativefoundation/store6/core/StoreKey;)V + public final fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; +} + +public final class org/mobilenativefoundation/store6/realtime/RealtimeMessage$Unchanged : org/mobilenativefoundation/store6/realtime/RealtimeMessage { + public fun (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/String;)V + public final fun getEtag ()Ljava/lang/String; + public final fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; +} + +public final class org/mobilenativefoundation/store6/realtime/RealtimeMessage$Upsert : org/mobilenativefoundation/store6/realtime/RealtimeMessage { + public fun (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/Object;Ljava/lang/String;)V + public final fun getEtag ()Ljava/lang/String; + public final fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; + public final fun getValue ()Ljava/lang/Object; +} + diff --git a/store6-realtime/api/store6-realtime.klib.api b/store6-realtime/api/store6-realtime.klib.api new file mode 100644 index 000000000..d0d4dfd22 --- /dev/null +++ b/store6-realtime/api/store6-realtime.klib.api @@ -0,0 +1,64 @@ +// Klib ABI Dump +// Targets: [iosArm64, iosSimulatorArm64, iosX64, js, linuxX64, macosArm64, mingwX64, tvosArm64, wasmJs, watchosArm64] +// Rendering settings: +// - Signature version: 2 +// - Show manifest properties: true +// - Show declarations: true + +// Library unique name: +sealed interface <#A: out org.mobilenativefoundation.store6.core/StoreKey, #B: out kotlin/Any> org.mobilenativefoundation.store6.realtime/RealtimeMessage { // org.mobilenativefoundation.store6.realtime/RealtimeMessage|null[0] + final class <#A1: out org.mobilenativefoundation.store6.core/StoreKey, #B1: out kotlin/Any> Upsert : org.mobilenativefoundation.store6.realtime/RealtimeMessage<#A1, #B1> { // org.mobilenativefoundation.store6.realtime/RealtimeMessage.Upsert|null[0] + constructor (#A1, #B1, kotlin/String?) // org.mobilenativefoundation.store6.realtime/RealtimeMessage.Upsert.|(1:0;1:1;kotlin.String?){}[0] + + final val etag // org.mobilenativefoundation.store6.realtime/RealtimeMessage.Upsert.etag|{}etag[0] + final fun (): kotlin/String? // org.mobilenativefoundation.store6.realtime/RealtimeMessage.Upsert.etag.|(){}[0] + final val key // org.mobilenativefoundation.store6.realtime/RealtimeMessage.Upsert.key|{}key[0] + final fun (): #A1 // org.mobilenativefoundation.store6.realtime/RealtimeMessage.Upsert.key.|(){}[0] + final val value // org.mobilenativefoundation.store6.realtime/RealtimeMessage.Upsert.value|{}value[0] + final fun (): #B1 // org.mobilenativefoundation.store6.realtime/RealtimeMessage.Upsert.value.|(){}[0] + } + + final class <#A1: out org.mobilenativefoundation.store6.core/StoreKey> Changed : org.mobilenativefoundation.store6.realtime/RealtimeMessage<#A1, kotlin/Nothing> { // org.mobilenativefoundation.store6.realtime/RealtimeMessage.Changed|null[0] + constructor (#A1) // org.mobilenativefoundation.store6.realtime/RealtimeMessage.Changed.|(1:0){}[0] + + final val key // org.mobilenativefoundation.store6.realtime/RealtimeMessage.Changed.key|{}key[0] + final fun (): #A1 // org.mobilenativefoundation.store6.realtime/RealtimeMessage.Changed.key.|(){}[0] + } + + final class <#A1: out org.mobilenativefoundation.store6.core/StoreKey> Deleted : org.mobilenativefoundation.store6.realtime/RealtimeMessage<#A1, kotlin/Nothing> { // org.mobilenativefoundation.store6.realtime/RealtimeMessage.Deleted|null[0] + constructor (#A1) // org.mobilenativefoundation.store6.realtime/RealtimeMessage.Deleted.|(1:0){}[0] + + final val key // org.mobilenativefoundation.store6.realtime/RealtimeMessage.Deleted.key|{}key[0] + final fun (): #A1 // org.mobilenativefoundation.store6.realtime/RealtimeMessage.Deleted.key.|(){}[0] + } + + final class <#A1: out org.mobilenativefoundation.store6.core/StoreKey> Unchanged : org.mobilenativefoundation.store6.realtime/RealtimeMessage<#A1, kotlin/Nothing> { // org.mobilenativefoundation.store6.realtime/RealtimeMessage.Unchanged|null[0] + constructor (#A1, kotlin/String?) // org.mobilenativefoundation.store6.realtime/RealtimeMessage.Unchanged.|(1:0;kotlin.String?){}[0] + + final val etag // org.mobilenativefoundation.store6.realtime/RealtimeMessage.Unchanged.etag|{}etag[0] + final fun (): kotlin/String? // org.mobilenativefoundation.store6.realtime/RealtimeMessage.Unchanged.etag.|(){}[0] + final val key // org.mobilenativefoundation.store6.realtime/RealtimeMessage.Unchanged.key|{}key[0] + final fun (): #A1 // org.mobilenativefoundation.store6.realtime/RealtimeMessage.Unchanged.key.|(){}[0] + } + + final class ChangedNamespace : org.mobilenativefoundation.store6.realtime/RealtimeMessage { // org.mobilenativefoundation.store6.realtime/RealtimeMessage.ChangedNamespace|null[0] + constructor (org.mobilenativefoundation.store6.core/StoreNamespace) // org.mobilenativefoundation.store6.realtime/RealtimeMessage.ChangedNamespace.|(org.mobilenativefoundation.store6.core.StoreNamespace){}[0] + + final val namespace // org.mobilenativefoundation.store6.realtime/RealtimeMessage.ChangedNamespace.namespace|{}namespace[0] + final fun (): org.mobilenativefoundation.store6.core/StoreNamespace // org.mobilenativefoundation.store6.realtime/RealtimeMessage.ChangedNamespace.namespace.|(){}[0] + } + + final object ChangedAll : org.mobilenativefoundation.store6.realtime/RealtimeMessage { // org.mobilenativefoundation.store6.realtime/RealtimeMessage.ChangedAll|null[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.mobilenativefoundation.store6.realtime/RealtimeMessage.ChangedAll.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.mobilenativefoundation.store6.realtime/RealtimeMessage.ChangedAll.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.mobilenativefoundation.store6.realtime/RealtimeMessage.ChangedAll.toString|toString(){}[0] + } +} + +final class <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> org.mobilenativefoundation.store6.realtime/RealtimeBinding { // org.mobilenativefoundation.store6.realtime/RealtimeBinding|null[0] + final suspend fun apply(org.mobilenativefoundation.store6.realtime/RealtimeMessage<#A, #B>) // org.mobilenativefoundation.store6.realtime/RealtimeBinding.apply|apply(org.mobilenativefoundation.store6.realtime.RealtimeMessage<1:0,1:1>){}[0] + final suspend fun consume(kotlinx.coroutines.flow/Flow>) // org.mobilenativefoundation.store6.realtime/RealtimeBinding.consume|consume(kotlinx.coroutines.flow.Flow>){}[0] +} + +final fun <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> org.mobilenativefoundation.store6.realtime/invalidatingRealtimeBinding(org.mobilenativefoundation.store6.core/Store<#A, #B>): org.mobilenativefoundation.store6.realtime/RealtimeBinding<#A, #B> // org.mobilenativefoundation.store6.realtime/invalidatingRealtimeBinding|invalidatingRealtimeBinding(org.mobilenativefoundation.store6.core.Store<0:0,0:1>){0§;1§}[0] +final fun <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> org.mobilenativefoundation.store6.realtime/realtimeBinding(org.mobilenativefoundation.store6.core/Store<#A, #B>): org.mobilenativefoundation.store6.realtime/RealtimeBinding<#A, #B> // org.mobilenativefoundation.store6.realtime/realtimeBinding|realtimeBinding(org.mobilenativefoundation.store6.core.Store<0:0,0:1>){0§;1§}[0] diff --git a/store6-realtime/build.gradle.kts b/store6-realtime/build.gradle.kts new file mode 100644 index 000000000..ccdcbcf05 --- /dev/null +++ b/store6-realtime/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + id("org.mobilenativefoundation.store.store6.multiplatform") +} + +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + api(projects.store6Core) + } + } + val commonTest by getting { + dependencies { + implementation(projects.store6Testing) + // Test-only: MutationStore is exercised as a Store implementation; the production + // adapter has no dependency on the mutations module. + implementation(projects.store6Mutations) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.turbine) + } + } + } +} + +android { + namespace = "org.mobilenativefoundation.store6.realtime" +} diff --git a/store6-realtime/gradle.properties b/store6-realtime/gradle.properties new file mode 100644 index 000000000..4891bb8d5 --- /dev/null +++ b/store6-realtime/gradle.properties @@ -0,0 +1,3 @@ +VERSION_NAME=6.0.0-SNAPSHOT +POM_NAME=store6-realtime +POM_ARTIFACT_ID=store6-realtime diff --git a/store6-realtime/sample/build.gradle.kts b/store6-realtime/sample/build.gradle.kts new file mode 100644 index 000000000..198bfe748 --- /dev/null +++ b/store6-realtime/sample/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + id("org.jetbrains.kotlin.jvm") + application +} + +kotlin { jvmToolchain(11) } + +dependencies { + implementation(projects.store6Realtime) +} + +application { mainClass.set("org.mobilenativefoundation.store6.realtime.sample.MainKt") } diff --git a/store6-realtime/sample/src/main/kotlin/org/mobilenativefoundation/store6/realtime/sample/Main.kt b/store6-realtime/sample/src/main/kotlin/org/mobilenativefoundation/store6/realtime/sample/Main.kt new file mode 100644 index 000000000..886960265 --- /dev/null +++ b/store6-realtime/sample/src/main/kotlin/org/mobilenativefoundation/store6/realtime/sample/Main.kt @@ -0,0 +1,131 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, +) + +package org.mobilenativefoundation.store6.realtime.sample + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.seam.Fetcher +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import org.mobilenativefoundation.store6.core.seam.KeyEvents +import org.mobilenativefoundation.store6.core.seam.runtime +import org.mobilenativefoundation.store6.core.store +import org.mobilenativefoundation.store6.realtime.RealtimeMessage +import org.mobilenativefoundation.store6.realtime.realtimeBinding + +public fun main(): Unit = + runBlocking { + withTimeout(SAMPLE_TIMEOUT_MILLIS) { + runSample() + } + } + +private suspend fun runSample() { + val backend = SampleBackend() + val store = + store { + fetcher(backend) + } + val binding = realtimeBinding(store) + val key = UserKey("42") + + try { + val cold = store.get(key) + check(cold.name == "User 42 v1") + check(backend.fetches == 1) + println("Scene 1: cold get; name=${cold.name}; fetches=1") + + val runtime = checkNotNull(store.runtime()) + coroutineScope { + val sotWrite = CompletableDeferred() + val collector = + launch(start = CoroutineStart.UNDISPATCHED) { + runtime.keyEvents.collect { event -> + if (event is KeyEvents.Written && event.origin == Origin.SOT) { + sotWrite.complete(Unit) + } + } + } + binding.apply( + RealtimeMessage.Upsert(key, User("42", "User 42 pushed"), etag = "push-1"), + ) + sotWrite.await() + collector.cancel() + } + check(store.get(key).name == "User 42 pushed") + check(backend.fetches == 1) + println("Scene 2: Upsert adopted as SOT; fetches still 1") + + backend.nextName = "User 42 v2" + binding.apply(RealtimeMessage.Changed(key)) + val refreshed = store.get(key, Freshness.MustBeFresh) + check(refreshed.name == "User 42 v2") + check(backend.fetches == 2) + check(backend.etags.last() == "push-1") + println("Scene 3: Changed refetched with push etag; fetches=2") + + backend.nextName = "User 42 v3" + binding.apply(RealtimeMessage.Deleted(key)) + val afterClear = store.get(key, Freshness.MustBeFresh) + check(afterClear.name == "User 42 v3") + check(backend.fetches == 3) + println("Scene 4: Deleted then refetch; fetches=3") + + backend.nextName = "User 42 v4" + binding.consume( + flowOf( + RealtimeMessage.ChangedNamespace(USERS), + RealtimeMessage.ChangedAll, + ), + ) + val afterWatermarks = store.get(key, Freshness.MustBeFresh) + check(afterWatermarks.name == "User 42 v4") + check(backend.fetches == 4) + println("Scene 5: consume namespace then global watermark; fetches=4") + } finally { + store.close() + } +} + +private data class UserKey( + val id: String, +) : StoreKey { + override val namespace: StoreNamespace = USERS + + override fun canonicalId(): String = id +} + +private data class User( + val id: String, + val name: String, +) + +private class SampleBackend : Fetcher { + var fetches: Int = 0 + private set + val etags = mutableListOf() + var nextName: String = "User 42 v1" + + override suspend fun fetch( + key: UserKey, + etag: String?, + ): FetcherResult { + fetches += 1 + etags += etag + return FetcherResult.Success(User(key.id, nextName), etag = "server-$fetches") + } +} + +private val USERS = StoreNamespace("users") +private const val SAMPLE_TIMEOUT_MILLIS = 20_000L diff --git a/store6-realtime/src/androidMain/AndroidManifest.xml b/store6-realtime/src/androidMain/AndroidManifest.xml new file mode 100644 index 000000000..8072ee00d --- /dev/null +++ b/store6-realtime/src/androidMain/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/store6-realtime/src/commonMain/kotlin/org/mobilenativefoundation/store6/realtime/RealtimeBinding.kt b/store6-realtime/src/commonMain/kotlin/org/mobilenativefoundation/store6/realtime/RealtimeBinding.kt new file mode 100644 index 000000000..b8970d2bc --- /dev/null +++ b/store6-realtime/src/commonMain/kotlin/org/mobilenativefoundation/store6/realtime/RealtimeBinding.kt @@ -0,0 +1,90 @@ +package org.mobilenativefoundation.store6.realtime + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreException +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.seam.StoreWriteHandle + +/** + * Interprets [RealtimeMessage] values as Store operations. + * + * Construct through [realtimeBinding] (adopting) or [invalidatingRealtimeBinding]. This type owns + * no [kotlinx.coroutines.CoroutineScope] and has nothing to close. The application owns the + * inbound connection, reconnect, and backoff. [consume] is one scheduler-agnostic pass: it + * applies messages in arrival order until the flow completes or a Store operation fails. + * + * [apply] is serialized per binding so an adopting [RealtimeMessage.Upsert] never interleaves its + * `apply` / `confirmFresh` pair with another caller. Cancellation of that pair can leave the + * value committed without bookkeeping success, the same non-atomic window as the mutations + * acknowledgement path. A fetch already in flight is not cancelled; a fetch commit that runs + * after `StoreWriteHandle.apply` is later source-of-truth authority. + * + * Failures from the bound Store propagate unchanged: [StoreException] for persistence and + * bookkeeping failures, and `IllegalStateException` with the message `Store is closed.` after + * [Store.close]. + * + * @param K the key type accepted by the bound Store + * @param V the non-null value type committed by an adopting [RealtimeMessage.Upsert] + */ +@ExperimentalStoreApi +public class RealtimeBinding internal constructor( + private val store: Store, + private val handle: StoreWriteHandle?, +) { + private val mutex = Mutex() + + /** + * Applies one [message] under the binding's mutex. + * + * @throws StoreException if the corresponding Store or write-handle operation fails + * @throws IllegalStateException if the bound Store is already closed + */ + public suspend fun apply(message: RealtimeMessage) { + mutex.withLock { + when (message) { + is RealtimeMessage.Upsert -> adoptOrInvalidate(message) + is RealtimeMessage.Unchanged -> handle?.confirmFresh(message.key, message.etag) + is RealtimeMessage.Changed -> markStale(message.key) + is RealtimeMessage.ChangedNamespace -> store.invalidateNamespace(message.namespace) + is RealtimeMessage.ChangedAll -> store.invalidateAll() + is RealtimeMessage.Deleted -> store.clear(message.key) + } + } + } + + /** + * Applies [messages] sequentially until the flow completes. + * + * A thrown Store failure ends the pass. Messages that already applied stay applied. The + * collector is cooperative with cancellation at each [apply] suspension point. + * + * @throws StoreException if a Store or write-handle operation fails + * @throws IllegalStateException if the bound Store is already closed + */ + public suspend fun consume(messages: Flow>) { + messages.collect { message -> apply(message) } + } + + private suspend fun adoptOrInvalidate(message: RealtimeMessage.Upsert) { + val writeHandle = handle + if (writeHandle != null) { + writeHandle.apply(message.key, message.value) + writeHandle.confirmFresh(message.key, message.etag) + } else { + store.invalidate(message.key) + } + } + + private suspend fun markStale(key: K) { + val writeHandle = handle + if (writeHandle != null) { + writeHandle.markStale(key) + } else { + store.invalidate(key) + } + } +} diff --git a/store6-realtime/src/commonMain/kotlin/org/mobilenativefoundation/store6/realtime/RealtimeBindings.kt b/store6-realtime/src/commonMain/kotlin/org/mobilenativefoundation/store6/realtime/RealtimeBindings.kt new file mode 100644 index 000000000..0bc21c0f8 --- /dev/null +++ b/store6-realtime/src/commonMain/kotlin/org/mobilenativefoundation/store6/realtime/RealtimeBindings.kt @@ -0,0 +1,38 @@ +package org.mobilenativefoundation.store6.realtime + +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.seam.runtime + +/** + * Creates an adopting binding that commits [RealtimeMessage.Upsert] through the engine write + * handle and confirms [RealtimeMessage.Unchanged] without fetching. + * + * @throws IllegalArgumentException if `store.runtime()` is `null`. `FakeStore`, decorators, and + * `MutationStore` withhold the handle. Use [invalidatingRealtimeBinding] in those cases. + */ +@ExperimentalStoreApi +public fun realtimeBinding(store: Store): RealtimeBinding { + val runtime = + store.runtime() + ?: throw IllegalArgumentException(ADOPTING_BINDING_UNAVAILABLE) + return RealtimeBinding(store, runtime.writeHandle) +} + +/** + * Creates a binding that never requires `store.runtime()`. + * + * [RealtimeMessage.Upsert] becomes `Store.invalidate(key)` so the next qualifying read + * refetches. [RealtimeMessage.Unchanged] is ignored. Use this on `MutationStore`, whose facade + * withholds the write handle, and on any other non-engine Store. + */ +@ExperimentalStoreApi +public fun invalidatingRealtimeBinding( + store: Store, +): RealtimeBinding = RealtimeBinding(store, handle = null) + +internal const val ADOPTING_BINDING_UNAVAILABLE: String = + "realtimeBinding requires an engine-backed Store. " + + "store.runtime() is null for FakeStore, decorators, and MutationStore. " + + "Use invalidatingRealtimeBinding(store) when adoption is unavailable." diff --git a/store6-realtime/src/commonMain/kotlin/org/mobilenativefoundation/store6/realtime/RealtimeMessage.kt b/store6-realtime/src/commonMain/kotlin/org/mobilenativefoundation/store6/realtime/RealtimeMessage.kt new file mode 100644 index 000000000..f0e852d9d --- /dev/null +++ b/store6-realtime/src/commonMain/kotlin/org/mobilenativefoundation/store6/realtime/RealtimeMessage.kt @@ -0,0 +1,88 @@ +package org.mobilenativefoundation.store6.realtime + +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace + +/** + * A server-authored fact for one Store identity or watermark. + * + * The application owns transport (WebSocket, SSE, or any other inbound stream) and maps each + * received frame onto one of these variants. [RealtimeBinding] then performs the corresponding + * Store operation. This type is not a wire format. + * + * @param K the key type accepted by the bound Store + * @param V the non-null value type committed by an [Upsert] + */ +@ExperimentalStoreApi +public sealed interface RealtimeMessage { + /** + * A present value that should become confirmed source-of-truth residence for [key]. + * + * An adopting [RealtimeBinding] commits [value] through `StoreWriteHandle.apply` and then + * `confirmFresh` with [etag]. That pair never fetches. [etag] is written as the new resident + * tag, including `null`. An invalidating binding has no write handle and marks [key] stale + * through `Store.invalidate` instead, so the next qualifying read refetches. + */ + @ExperimentalStoreApi + public class Upsert( + public val key: K, + public val value: V, + public val etag: String?, + ) : RealtimeMessage + + /** + * Confirmation that the resident value for [key] is still current, analogous to a + * `304 Not Modified`. + * + * An adopting [RealtimeBinding] calls `StoreWriteHandle.confirmFresh` with [etag] and does + * not fetch. With no resident value that call is a no-op. [etag] is written as the new + * resident tag, including `null`. An invalidating binding ignores this variant. + */ + @ExperimentalStoreApi + public class Unchanged( + public val key: K, + public val etag: String?, + ) : RealtimeMessage + + /** + * A per-key stale mark, identical to `Store.invalidate(key)` / + * `StoreWriteHandle.markStale(key)`. + * + * The resident value is kept. Active streams of [key] are signaled to refetch. The mark is + * durable until a later successful fetch or `confirmFresh`. + */ + @ExperimentalStoreApi + public class Changed( + public val key: K, + ) : RealtimeMessage + + /** + * A namespace watermark, identical to `Store.invalidateNamespace(namespace)`. + * + * The watermark covers keys in [namespace] whether or not they are currently resident. + */ + @ExperimentalStoreApi + public class ChangedNamespace( + public val namespace: StoreNamespace, + ) : RealtimeMessage + + /** + * A global watermark, identical to `Store.invalidateAll()`. + * + * The watermark covers every namespace, including keys that are not currently resident. + */ + @ExperimentalStoreApi + public data object ChangedAll : RealtimeMessage + + /** + * A destructive removal, identical to `Store.clear(key)`. + * + * On return the resident value is gone. Active streams observe the absent-value transition + * and then refetched data. + */ + @ExperimentalStoreApi + public class Deleted( + public val key: K, + ) : RealtimeMessage +} diff --git a/store6-realtime/src/commonTest/kotlin/org/mobilenativefoundation/store6/realtime/RealtimeAdoptionTest.kt b/store6-realtime/src/commonTest/kotlin/org/mobilenativefoundation/store6/realtime/RealtimeAdoptionTest.kt new file mode 100644 index 000000000..b577c738f --- /dev/null +++ b/store6-realtime/src/commonTest/kotlin/org/mobilenativefoundation/store6/realtime/RealtimeAdoptionTest.kt @@ -0,0 +1,178 @@ +@file:OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) + +package org.mobilenativefoundation.store6.realtime + +import app.cash.turbine.test +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.store +import org.mobilenativefoundation.store6.testing.FakeBookkeeper +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +class RealtimeAdoptionTest { + @Test + fun upsert_emitsSotData_withoutFetch() = runTest { + val fetcher = RecordingFetcher() + val store = store { fetcher(fetcher) } + val binding = realtimeBinding(store) + val key = RealtimeTestKey("adopt") + + try { + store.stream(key).test { + val fetched = awaitDataValue("fetched-1") + assertEquals(Origin.FETCHER, fetched.origin) + assertEquals(1, fetcher.fetches) + + binding.apply(RealtimeMessage.Upsert(key, "pushed", etag = "push-etag")) + + val adopted = awaitDataValue("pushed") + assertEquals(Origin.SOT, adopted.origin) + assertEquals(1, fetcher.fetches) + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun upsert_recordsBookkeepingSuccess_laterCachedOrFetchSkipsFetch() = runTest { + val fetcher = RecordingFetcher() + val bookkeeper = FakeBookkeeper() + val store = + store { + fetcher(fetcher) + bookkeeper(bookkeeper) + } + val binding = realtimeBinding(store) + val key = RealtimeTestKey("fresh") + + try { + assertEquals("fetched-1", store.get(key)) + assertEquals(1, fetcher.fetches) + store.invalidate(key) + assertTrue(assertNotNull(bookkeeper.status(key)).durablyStale) + + binding.apply(RealtimeMessage.Upsert(key, "pushed", etag = "push-etag")) + + assertFalse(assertNotNull(bookkeeper.status(key)).durablyStale) + assertEquals("pushed", store.get(key, Freshness.CachedOrFetch)) + assertEquals(1, fetcher.fetches) + } finally { + store.close() + } + } + + @Test + fun upsert_etag_flowsIntoNextConditionalFetch() = runTest { + val fetcher = RecordingFetcher() + val store = store { fetcher(fetcher) } + val binding = realtimeBinding(store) + val key = RealtimeTestKey("etag") + + try { + assertEquals("fetched-1", store.get(key)) + assertEquals(listOf(null), fetcher.etags) + + binding.apply(RealtimeMessage.Upsert(key, "pushed", etag = "push-etag")) + store.invalidate(key) + assertEquals("fetched-2", store.get(key, Freshness.MustBeFresh)) + + assertEquals(listOf(null, "push-etag"), fetcher.etags) + assertEquals(2, fetcher.fetches) + } finally { + store.close() + } + } + + @Test + fun upsert_applyThenConfirmFresh_ordering() = runTest { + val store = store { fetcher { "fetched" } } + val handle = RecordingWriteHandle() + val binding = RealtimeBinding(store, handle) + val key = RealtimeTestKey("order") + + try { + binding.apply(RealtimeMessage.Upsert(key, "pushed", etag = "ack-etag")) + assertEquals(listOf("apply", "confirmFresh"), handle.events) + } finally { + store.close() + } + } + + @Test + fun unchanged_clearsDurableStalenessWithoutFetch() = runTest { + val fetcher = RecordingFetcher() + val bookkeeper = FakeBookkeeper() + val store = + store { + fetcher(fetcher) + bookkeeper(bookkeeper) + } + val binding = realtimeBinding(store) + val key = RealtimeTestKey("304") + + try { + assertEquals("fetched-1", store.get(key)) + store.invalidate(key) + assertTrue(assertNotNull(bookkeeper.status(key)).durablyStale) + + binding.apply(RealtimeMessage.Unchanged(key, etag = "still-fresh")) + + assertFalse(assertNotNull(bookkeeper.status(key)).durablyStale) + assertEquals("fetched-1", store.get(key, Freshness.CachedOrFetch)) + assertEquals(1, fetcher.fetches) + } finally { + store.close() + } + } + + @Test + fun unchanged_withoutResidence_isNoOp() = runTest { + val fetcher = RecordingFetcher() + val store = store { fetcher(fetcher) } + val binding = realtimeBinding(store) + val key = RealtimeTestKey("absent") + + try { + binding.apply(RealtimeMessage.Unchanged(key, etag = "unused")) + assertEquals(0, fetcher.fetches) + assertEquals("fetched-1", store.get(key)) + assertEquals(1, fetcher.fetches) + } finally { + store.close() + } + } +} + +private suspend fun app.cash.turbine.ReceiveTurbine>.awaitDataValue( + expected: String, +): StoreResult.Data { + while (true) { + val item = awaitItem() + if (item is StoreResult.Data && item.value == expected) return item + } +} + +// Turbine's 3s default would nest inside the 25s shadow. Raising the Turbine deadline above +// the shadow makes runTest the only effective timeout. +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds // strictly > TEST_TIMEOUT: the shadow must fire first + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } diff --git a/store6-realtime/src/commonTest/kotlin/org/mobilenativefoundation/store6/realtime/RealtimeBindingLifecycleTest.kt b/store6-realtime/src/commonTest/kotlin/org/mobilenativefoundation/store6/realtime/RealtimeBindingLifecycleTest.kt new file mode 100644 index 000000000..416ca130e --- /dev/null +++ b/store6-realtime/src/commonTest/kotlin/org/mobilenativefoundation/store6/realtime/RealtimeBindingLifecycleTest.kt @@ -0,0 +1,199 @@ +@file:OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) + +package org.mobilenativefoundation.store6.realtime + +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.seam.StoreWriteHandle +import org.mobilenativefoundation.store6.core.store +import org.mobilenativefoundation.store6.testing.FakeStore +import org.mobilenativefoundation.store6.testing.FakeStoreInteraction +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.time.Duration.Companion.seconds + +class RealtimeBindingLifecycleTest { + @Test + fun consume_appliesMessagesInOrder_thenCompletes() = runTest { + val store = store { fetcher { "fetched" } } + val handle = RecordingWriteHandle() + val binding = RealtimeBinding(store, handle) + val key = RealtimeTestKey("consume") + + try { + binding.consume( + flowOf( + RealtimeMessage.Upsert(key, "a", etag = "e1"), + RealtimeMessage.Changed(key), + RealtimeMessage.Unchanged(key, etag = "e2"), + ), + ) + assertEquals(listOf("apply", "confirmFresh", "markStale", "confirmFresh"), handle.events) + } finally { + store.close() + } + } + + @Test + fun consume_propagatesError_andStops() = runTest { + val store = store { fetcher { "fetched" } } + val handle = + object : StoreWriteHandle { + val events = mutableListOf() + + override suspend fun apply( + key: RealtimeTestKey, + value: String, + ) { + events += "apply:$value" + if (value == "boom") error("upsert failed") + } + + override suspend fun markStale(key: RealtimeTestKey) { + events += "markStale" + } + + override suspend fun confirmFresh( + key: RealtimeTestKey, + etag: String?, + ) { + events += "confirmFresh" + } + } + val binding = RealtimeBinding(store, handle) + val key = RealtimeTestKey("fail") + + try { + val failure = + assertFailsWith { + binding.consume( + flowOf( + RealtimeMessage.Upsert(key, "ok", etag = null), + RealtimeMessage.Upsert(key, "boom", etag = null), + RealtimeMessage.Changed(key), + ), + ) + } + assertEquals("upsert failed", failure.message) + assertEquals(listOf("apply:ok", "confirmFresh", "apply:boom"), handle.events) + } finally { + store.close() + } + } + + @Test + fun concurrentApply_serializesUpsertPair() = runTest { + val store = store { fetcher { "fetched" } } + val applyStarted = CompletableDeferred() + val releaseApply = CompletableDeferred() + val handle = + object : StoreWriteHandle { + val events = mutableListOf() + private var applies = 0 + + override suspend fun apply( + key: RealtimeTestKey, + value: String, + ) { + events += "apply" + applies += 1 + if (applies == 1) { + applyStarted.complete(Unit) + releaseApply.await() + } + } + + override suspend fun markStale(key: RealtimeTestKey) = Unit + + override suspend fun confirmFresh( + key: RealtimeTestKey, + etag: String?, + ) { + events += "confirmFresh" + } + } + val binding = RealtimeBinding(store, handle) + val key = RealtimeTestKey("serial") + + try { + val first = async { binding.apply(RealtimeMessage.Upsert(key, "a", etag = "e1")) } + applyStarted.await() + val second = async { binding.apply(RealtimeMessage.Upsert(key, "b", etag = "e2")) } + releaseApply.complete(Unit) + first.await() + second.await() + assertEquals(listOf("apply", "confirmFresh", "apply", "confirmFresh"), handle.events) + } finally { + store.close() + } + } + + @Test + fun apply_onClosedStore_throws() = runTest { + val store = store { fetcher { "fetched" } } + val binding = realtimeBinding(store) + val key = RealtimeTestKey("closed") + store.close() + + val failure = + assertFailsWith { + binding.apply(RealtimeMessage.Changed(key)) + } + assertEquals("Store is closed.", failure.message) + } + + @Test + fun consume_onClosedStore_throws() = runTest { + val store = store { fetcher { "fetched" } } + val binding = realtimeBinding(store) + store.close() + + val failure = + assertFailsWith { + binding.consume(flow { emit(RealtimeMessage.ChangedAll) }) + } + assertEquals("Store is closed.", failure.message) + } + + @Test + fun realtimeBinding_rejectsFakeStore_withPinnedMessage() = runTest { + val store = FakeStore() + try { + val failure = assertFailsWith { realtimeBinding(store) } + assertEquals(ADOPTING_BINDING_UNAVAILABLE, failure.message) + } finally { + store.close() + } + } + + @Test + fun invalidatingBinding_acceptsFakeStore() = runTest { + val store = FakeStore() + try { + invalidatingRealtimeBinding(store).apply(RealtimeMessage.ChangedAll) + assertEquals(1, store.interactions.filterIsInstance().size) + } finally { + store.close() + } + } +} + +// Turbine's 3s default would nest inside the 25s shadow. Raising the Turbine deadline above +// the shadow makes runTest the only effective timeout. +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds // strictly > TEST_TIMEOUT: the shadow must fire first + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } diff --git a/store6-realtime/src/commonTest/kotlin/org/mobilenativefoundation/store6/realtime/RealtimeInvalidationTest.kt b/store6-realtime/src/commonTest/kotlin/org/mobilenativefoundation/store6/realtime/RealtimeInvalidationTest.kt new file mode 100644 index 000000000..93e18f541 --- /dev/null +++ b/store6-realtime/src/commonTest/kotlin/org/mobilenativefoundation/store6/realtime/RealtimeInvalidationTest.kt @@ -0,0 +1,257 @@ +@file:OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) + +package org.mobilenativefoundation.store6.realtime + +import app.cash.turbine.test +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.seam.runtime +import org.mobilenativefoundation.store6.core.store +import org.mobilenativefoundation.store6.testing.FakeBookkeeper +import org.mobilenativefoundation.store6.testing.FakeStore +import org.mobilenativefoundation.store6.testing.FakeStoreInteraction +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.seconds + +class RealtimeInvalidationTest { + @Test + fun changed_signalsActiveStreamToRefetch() = runTest { + val fetcher = RecordingFetcher() + val store = store { fetcher(fetcher) } + val binding = realtimeBinding(store) + val key = RealtimeTestKey("changed") + + try { + store.stream(key).test { + awaitDataValue("fetched-1") + binding.apply(RealtimeMessage.Changed(key)) + awaitDataValue("fetched-2") + assertEquals(2, fetcher.fetches) + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun changedNamespace_marksResidentAndCoversNeverFetchedKey() = runTest { + val fetcher = RecordingFetcher() + val bookkeeper = FakeBookkeeper() + val store = + store { + fetcher(fetcher) + bookkeeper(bookkeeper) + } + val binding = realtimeBinding(store) + val resident = RealtimeTestKey("resident") + val unseen = RealtimeTestKey("unseen") + + try { + assertEquals("fetched-1", store.get(resident)) + assertEquals("fetched-1", store.get(resident, Freshness.MaxAge(1.days))) + assertEquals(1, fetcher.fetches) + assertNull(bookkeeper.status(unseen)) + + binding.apply(RealtimeMessage.ChangedNamespace(REALTIME_NAMESPACE)) + + assertTrue(assertNotNull(bookkeeper.status(resident)).durablyStale) + assertTrue(assertNotNull(bookkeeper.status(unseen)).durablyStale) + assertEquals("fetched-2", store.get(resident, Freshness.MaxAge(1.days))) + assertEquals(2, fetcher.fetches) + } finally { + store.close() + } + } + + @Test + fun changedAll_marksResidentStale() = runTest { + val fetcher = RecordingFetcher() + val bookkeeper = FakeBookkeeper() + val store = + store { + fetcher(fetcher) + bookkeeper(bookkeeper) + } + val binding = realtimeBinding(store) + val key = RealtimeTestKey("global") + + try { + assertEquals("fetched-1", store.get(key)) + assertEquals("fetched-1", store.get(key, Freshness.MaxAge(1.days))) + assertEquals(1, fetcher.fetches) + + binding.apply(RealtimeMessage.ChangedAll) + + assertTrue(assertNotNull(bookkeeper.status(key)).durablyStale) + assertEquals("fetched-2", store.get(key, Freshness.MaxAge(1.days))) + assertEquals(2, fetcher.fetches) + } finally { + store.close() + } + } + + @Test + fun deleted_emitsLoadingThenRefetch() = runTest { + val fetcher = RecordingFetcher() + val store = store { fetcher(fetcher) } + val binding = realtimeBinding(store) + val key = RealtimeTestKey("deleted") + + try { + store.stream(key).test { + awaitDataValue("fetched-1") + binding.apply(RealtimeMessage.Deleted(key)) + // Fenced clear: an already-active pipeline may queue one duplicate pre-clear + // Data frame. Drain it exactly; Loading then refetched data must still follow. + var frame = awaitItem() + var queuedPreClearReplays = 0 + while (frame !is StoreResult.Loading) { + queuedPreClearReplays += 1 + assertEquals(1, queuedPreClearReplays, "more than one queued pre-clear replay") + assertEquals("fetched-1", assertIs>(frame).value) + frame = awaitItem() + } + awaitDataValue("fetched-2") + assertEquals(2, fetcher.fetches) + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun invalidatingBinding_upsertDegradesToInvalidate_onFakeStore() = runTest { + val store = FakeStore() + val key = RealtimeTestKey("fake-upsert") + store.setValue(key, "resident") + store.clearInteractions() + val binding = invalidatingRealtimeBinding(store) + + try { + binding.apply(RealtimeMessage.Upsert(key, "ignored", etag = "etag")) + val invalidate = store.interactions.filterIsInstance() + assertEquals(1, invalidate.size) + assertEquals(key.canonicalId(), invalidate.single().key.canonicalId()) + assertTrue(store.interactions.none { it is FakeStoreInteraction.Clear }) + } finally { + store.close() + } + } + + @Test + fun invalidatingBinding_unchangedIsNoOp_onFakeStore() = runTest { + val store = FakeStore() + val key = RealtimeTestKey("fake-unchanged") + store.setValue(key, "resident") + store.clearInteractions() + val binding = invalidatingRealtimeBinding(store) + + try { + binding.apply(RealtimeMessage.Unchanged(key, etag = "etag")) + assertEquals(emptyList(), store.interactions) + } finally { + store.close() + } + } + + @Test + fun invalidatingBinding_changedAndDeleted_recordOnFakeStore() = runTest { + val store = FakeStore() + val key = RealtimeTestKey("fake-ops") + store.setValue(key, "resident") + val binding = invalidatingRealtimeBinding(store) + + try { + store.clearInteractions() + binding.apply(RealtimeMessage.Changed(key)) + assertEquals(1, store.interactions.filterIsInstance().size) + + store.clearInteractions() + binding.apply(RealtimeMessage.ChangedNamespace(REALTIME_NAMESPACE)) + assertEquals( + 1, + store.interactions.filterIsInstance().size, + ) + + store.clearInteractions() + binding.apply(RealtimeMessage.ChangedAll) + assertEquals(1, store.interactions.filterIsInstance().size) + + store.clearInteractions() + binding.apply(RealtimeMessage.Deleted(key)) + assertEquals(1, store.interactions.filterIsInstance().size) + } finally { + store.close() + } + } + + @Test + fun invalidatingBinding_worksOnMutationStoreFacade() = runTest { + var fetches = 0 + val store = + mutationStoreForRealtime { key -> + fetches += 1 + "v$fetches-${key.canonicalId()}" + } + val binding = invalidatingRealtimeBinding(store) + val key = RealtimeTestKey("mutation") + + try { + assertNull(store.runtime()) + assertEquals("v1-mutation", store.get(key)) + assertEquals("v1-mutation", store.get(key, Freshness.MaxAge(1.days))) + assertEquals(1, fetches) + binding.apply(RealtimeMessage.Upsert(key, "ignored", etag = "etag")) + assertEquals("v2-mutation", store.get(key, Freshness.MaxAge(1.days))) + assertEquals(2, fetches) + } finally { + store.close() + } + } + + @Test + fun realtimeBinding_rejectsMutationStore() = runTest { + val store = mutationStoreForRealtime { "unused" } + try { + val failure = assertFailsWith { realtimeBinding(store) } + assertEquals(ADOPTING_BINDING_UNAVAILABLE, failure.message) + } finally { + store.close() + } + } +} + +private suspend fun app.cash.turbine.ReceiveTurbine>.awaitDataValue( + expected: String, +): StoreResult.Data { + while (true) { + val item = awaitItem() + if (item is StoreResult.Data && item.value == expected) return item + } +} + +// Turbine's 3s default would nest inside the 25s shadow. Raising the Turbine deadline above +// the shadow makes runTest the only effective timeout. +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds // strictly > TEST_TIMEOUT: the shadow must fire first + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } diff --git a/store6-realtime/src/commonTest/kotlin/org/mobilenativefoundation/store6/realtime/RealtimeTestFixtures.kt b/store6-realtime/src/commonTest/kotlin/org/mobilenativefoundation/store6/realtime/RealtimeTestFixtures.kt new file mode 100644 index 000000000..47a933dc2 --- /dev/null +++ b/store6-realtime/src/commonTest/kotlin/org/mobilenativefoundation/store6/realtime/RealtimeTestFixtures.kt @@ -0,0 +1,114 @@ +@file:OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) + +package org.mobilenativefoundation.store6.realtime + +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.seam.Fetcher +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import org.mobilenativefoundation.store6.core.seam.StoreWriteHandle +import org.mobilenativefoundation.store6.mutations.MutationAck +import org.mobilenativefoundation.store6.mutations.MutationCodec +import org.mobilenativefoundation.store6.mutations.MutationKeyIdentity +import org.mobilenativefoundation.store6.mutations.MutationKeyResolver +import org.mobilenativefoundation.store6.mutations.MutationPresentAck +import org.mobilenativefoundation.store6.mutations.MutationPush +import org.mobilenativefoundation.store6.mutations.MutationRetirement +import org.mobilenativefoundation.store6.mutations.MutationRetirementAck +import org.mobilenativefoundation.store6.mutations.MutationServer +import org.mobilenativefoundation.store6.mutations.MutationStore +import org.mobilenativefoundation.store6.mutations.mutationStore +import org.mobilenativefoundation.store6.mutations.mutatorRegistry + +internal class RealtimeTestKey( + private val id: String, + override val namespace: StoreNamespace = REALTIME_NAMESPACE, +) : StoreKey { + override fun canonicalId(): String = id +} + +internal val REALTIME_NAMESPACE = StoreNamespace("realtime") + +internal class RecordingWriteHandle : StoreWriteHandle { + val events = mutableListOf() + + override suspend fun apply( + key: RealtimeTestKey, + value: String, + ) { + events += "apply" + } + + override suspend fun markStale(key: RealtimeTestKey) { + events += "markStale" + } + + override suspend fun confirmFresh( + key: RealtimeTestKey, + etag: String?, + ) { + events += "confirmFresh" + } +} + +internal class RecordingFetcher : Fetcher { + val etags = mutableListOf() + var fetches: Int = 0 + private set + + override suspend fun fetch( + key: RealtimeTestKey, + etag: String?, + ): FetcherResult { + fetches += 1 + etags += etag + return FetcherResult.Success("fetched-$fetches", etag = "server-$fetches") + } +} + +internal object RealtimeTestKeyResolver : MutationKeyResolver { + override suspend fun resolve(identity: MutationKeyIdentity): RealtimeTestKey? = + if (identity.namespace == REALTIME_NAMESPACE.value) { + RealtimeTestKey(identity.canonicalId) + } else { + null + } +} + +internal object RealtimeStringCodec : MutationCodec { + override fun encode(value: String): ByteArray = value.encodeToByteArray() + + override fun decode( + version: Int, + bytes: ByteArray, + ): String = bytes.decodeToString() +} + +internal class UnusedMutationServer : MutationServer { + override suspend fun push( + request: MutationPush, + ): MutationAck = + MutationPresentAck( + authoritative = "unused", + etag = null, + canonicalKey = null, + ) + + override suspend fun retire(request: MutationRetirement): MutationRetirementAck = + MutationRetirementAck(confirmedThroughSequence = request.retiredThroughSequence) +} + +internal fun mutationStoreForRealtime( + fetch: suspend (RealtimeTestKey) -> String, +): MutationStore = + mutationStore( + registry = mutatorRegistry { }, + server = UnusedMutationServer(), + keyResolver = RealtimeTestKeyResolver, + valueCodecVersion = 1, + valueCodec = RealtimeStringCodec, + ) { + fetcher { fetch(it) } + } diff --git a/store6-room/README.md b/store6-room/README.md new file mode 100644 index 000000000..f4853161c --- /dev/null +++ b/store6-room/README.md @@ -0,0 +1,221 @@ +# Store6 Room + +`store6-room` connects Store6 to an existing Room database. Your DAO remains the +source of truth for application values. The adapter adds two sidecar tables for +freshness metadata and durable invalidation. + +The Store6 seam is a **freeze candidate, not frozen** — see [STABILITY.md](../STABILITY.md). + +## 15-minute existing-database walkthrough + +The runnable sample starts with a version-1 database that contains only a +`users` table. It seeds a row and closes the database before Store6 is added. + +### 1. Add the dependencies (2 minutes) + +Use the same Store6 version for `store6-core`, `store6-room`, and +`store6-testing`. Replace `` with the release you consume: + +```kotlin +plugins { + kotlin("jvm") version "2.3.20" + id("com.google.devtools.ksp") version "2.3.10" + id("androidx.room3") version "3.0.0" +} + +kotlin { + sourceSets { + getByName("main") + .languageSettings + .optIn("org.mobilenativefoundation.store6.core.ExperimentalStoreApi") + } +} + +dependencies { + implementation("org.mobilenativefoundation.store:store6-core:") + implementation("org.mobilenativefoundation.store:store6-room:") + implementation("androidx.room3:room3-runtime:3.0.0") + implementation("androidx.sqlite:sqlite-bundled:2.7.0") + ksp("androidx.room3:room3-compiler:3.0.0") +} +``` + +The `androidx.room3` Gradle plugin registers its extension as `room3` (not Room 2's `room`), +so a schema directory is declared with `room3 { schemaDirectory(...) }`. This release line also +requires Kotlin ≥2.3 and, on Android, AGP ≥8.10. + +Use the corresponding source set if your database is in a multiplatform +module. This source-set opt-in also covers Room's generated DAO implementation; +a file-level opt-in covers only the file that declares it. + +The sample module uses project dependencies, so it is runnable directly from +this checkout. + +### 2. Make the three-declaration database diff (3 minutes) + +Keep every existing entity and DAO. Add the two adapter entities and one DAO +accessor: + +```kotlin +@Database( + entities = [ + UserEntity::class, + Store6BookkeepingEntity::class, + Store6WatermarkEntity::class, + ], + version = 2, +) +abstract class AppDatabase : RoomDatabase() { + abstract fun userDao(): UserDao + abstract fun store6BookkeeperDao(): Store6BookkeeperDao +} +``` + +This is the precise schema claim: Store6 changes no columns or constraints in +your tables. It adds two sidecar tables, which requires one database-version +bump and one migration. + +### 3. Add the migration (2 minutes) + +```kotlin +val addStore6Tables = + object : Migration(1, 2) { + // androidx.room3 Migration.migrate is suspend. Store6RoomSchema.createTables is not: + // androidx.sqlite 2.7.0's execSQL is synchronous, so it is called directly. + override suspend fun migrate(connection: SQLiteConnection) = + Store6RoomSchema.createTables(connection) + } + +Room.databaseBuilder(name = databasePath) + .addMigrations(addStore6Tables) +``` + +`Store6RoomSchema.createTables` creates `store6_bookkeeping` and +`store6_watermarks`. It does not alter `users`. + +### 4. Wire Store6 to your DAO (5 minutes) + +```kotlin +@file:OptIn(ExperimentalStoreApi::class) + +val dao = database.userDao() +val users = + store { + fetcher { key -> api.getUser(key.id) } + persistence( + RoomSourceOfTruth( + database = database, + rowReader = { key -> + dao.user(key.id).map { row -> row?.toUser() } + }, + rowWriter = { _, user -> dao.upsert(user.toEntity()) }, + rowDeleter = { key -> dao.delete(key.id) }, + namespaceDeleter = { _ -> dao.deleteAll() }, + allDeleter = { dao.deleteAll() }, + ), + ) + bookkeeper( + RoomBookkeeper( + database = database, + dao = database.store6BookkeeperDao(), + ), + ) + } +``` + +The adapter needs the same `RoomDatabase` for the source of truth and +bookkeeper so their operations use the same database lifecycle. + +### 5. Run and inspect the walkthrough (3 minutes) + +```shell +./gradlew :store6-room-sample:run +``` + +The program checks four observable behaviors and exits nonzero if any check +fails: + +1. The legacy row is served from the source of truth with no fetch. +2. A cold key emits `Loading`, then `Data` from the fetcher. +3. A new Store and Room instance over the same file serves that row fresh and + non-refreshing from the source of truth, without another fetch, because its + metadata is durable. +4. Namespace invalidation survives another rebuild and forces a refetch. + +Durability lives in your Room rows plus the adapter sidecar tables, not in +retained Store engines. Quiescent engine residency is bounded by `maxIdleKeys` +(128 by default). `close()` is synchronous and idempotent, cancels active Store +work, and releases engine residence. An operation started after close fails +immediately with `IllegalStateException("Store is closed.")`. + +### Honest timing checklist + +Run this against a real existing Room project before calling the 15-minute +claim verified: + +- [ ] Start a timer before editing. +- [ ] By 2:00, add the five dependency lines and source-set opt-in. +- [ ] By 5:00, add the two entities and DAO accessor. +- [ ] By 7:00, add and register the migration. +- [ ] By 12:00, map the existing DAO into `RoomSourceOfTruth` and add + `RoomBookkeeper`. +- [ ] By 15:00, run one legacy read, one cold fetch, one rebuild, and one + invalidation/rebuild. +- [ ] Record the date, machine, starting project, elapsed time, and any + corrections: `________________________________________`. + +## Testing your wiring + +Add `org.mobilenativefoundation.store:store6-testing:` to the test +source set. Extend `SourceOfTruthContractKit` for your +`RoomSourceOfTruth` fixture and `BookkeeperContractKit` for your +`RoomBookkeeper` fixture. Return a fresh database-backed adapter from each +factory method. + +The inherited suites cover 15 source-of-truth contracts and 6 bookkeeping +contracts on every target where you execute them. Their close and lifecycle +semantics are final. + +## Compatibility statement + +store6-room targets **androidx.room3 3.0.0** (Room's KMP-first line), +pinned with Kotlin **2.3.20** and androidx.sqlite **2.7.0**. These versions are +KLIB-ABI locked and Renovate-frozen; they move only with the repository's +toolchain. + +Supported targets: + +- android +- jvm +- iosArm64 +- iosSimulatorArm64 +- macosArm64 +- watchosArm64 +- tvosArm64 +- linuxX64 + +**Gaps vs `store6-core`'s 12 targets: js, wasmJs, mingwX64, and iosX64.** +`androidx.room3` publishes no iosX64 variant. js and wasmJs *are* supported by that release line and are planned +as a follow-up — they require a suspend schema API and async SQLite integration. +Use `store6-sqldelight` or a custom source of truth on those targets meanwhile. + +Android consumers need **compileSdk 34+** — the floor room3's AAR metadata +declares — and **minSdk 24+**. This repository builds the AAR at compileSdk 36. +An AAR built at minSdk 24 cannot be consumed by an application with a lower +minimum. + +**Room 2.x is not supported by this artifact.** store6-room launched on the `androidx.room3` line; +it never shipped a Room 2 release. That line uses new coordinates, packages, and +type identity, and its native KLIBs require Kotlin ≥2.3 with AGP ≥8.10 — it is +gated on both a toolchain move and a migration, not one or the other. Room 2 +applications should migrate the app database to `androidx.room3` (Google documents 2.x/3.x +coexistence) or use `store6-sqldelight`. + +Tests execute on jvm, linuxX64, iosSimulatorArm64, and macosArm64. Android, +iosArm64, watchosArm64, and tvosArm64 are compile-only in this repository. CI +also exercises cross-module Room code generation, where user databases reference +adapter entities and DAO types from a dependency KLIB. + +One conservative edge remains at the exact transaction commit boundary: +cancellation can commit a Room mutation while surfacing cancellation to the +caller. `RoomSourceOfTruth` documents the recovery behavior in its KDoc. diff --git a/store6-room/api/android/store6-room.api b/store6-room/api/android/store6-room.api new file mode 100644 index 000000000..bbe12f788 --- /dev/null +++ b/store6-room/api/android/store6-room.api @@ -0,0 +1,58 @@ +public final class org/mobilenativefoundation/store6/room/RoomBookkeeper : org/mobilenativefoundation/store6/core/seam/Bookkeeper { + public fun (Landroidx/room3/RoomDatabase;Lorg/mobilenativefoundation/store6/room/Store6BookkeeperDao;)V + public fun advanceGlobalStaleWatermark (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun advanceStaleWatermark (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun forget (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun forgetAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun forgetNamespace (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun markStale (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun recordFailure (Lorg/mobilenativefoundation/store6/core/StoreKey;JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun recordSuccess (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/StoreMeta;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun status (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class org/mobilenativefoundation/store6/room/RoomSourceOfTruth : org/mobilenativefoundation/store6/core/seam/TransactionalSourceOfTruth { + public fun (Landroidx/room3/RoomDatabase;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)V + public fun delete (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteNamespace (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun reader (Lorg/mobilenativefoundation/store6/core/StoreKey;)Lkotlinx/coroutines/flow/Flow; + public fun withTransaction (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun write (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class org/mobilenativefoundation/store6/room/Store6BookkeeperDao { + public abstract fun deleteAllRecords (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteNamespaceRecords (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteRecord (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun record (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun upsertRecord (Lorg/mobilenativefoundation/store6/room/Store6BookkeepingEntity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun upsertWatermark (Lorg/mobilenativefoundation/store6/room/Store6WatermarkEntity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun watermark (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class org/mobilenativefoundation/store6/room/Store6BookkeepingEntity { + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;ILjava/lang/Long;)V + public final fun getCanonicalId ()Ljava/lang/String; + public final fun getConsecutiveFailures ()I + public final fun getEtag ()Ljava/lang/String; + public final fun getLastFailureAtEpochMillis ()Ljava/lang/Long; + public final fun getLastSuccessSequence ()Ljava/lang/Long; + public final fun getNamespace ()Ljava/lang/String; + public final fun getStaleSequence ()Ljava/lang/Long; + public final fun getWrittenAtEpochMillis ()Ljava/lang/Long; +} + +public final class org/mobilenativefoundation/store6/room/Store6RoomSchema { + public static final field CREATE_BOOKKEEPING_TABLE Ljava/lang/String; + public static final field CREATE_WATERMARKS_TABLE Ljava/lang/String; + public static final field INSTANCE Lorg/mobilenativefoundation/store6/room/Store6RoomSchema; + public final fun createTables (Landroidx/sqlite/SQLiteConnection;)V +} + +public final class org/mobilenativefoundation/store6/room/Store6WatermarkEntity { + public fun (Ljava/lang/String;J)V + public final fun getScope ()Ljava/lang/String; + public final fun getSequence ()J +} + diff --git a/store6-room/api/jvm/store6-room.api b/store6-room/api/jvm/store6-room.api new file mode 100644 index 000000000..bbe12f788 --- /dev/null +++ b/store6-room/api/jvm/store6-room.api @@ -0,0 +1,58 @@ +public final class org/mobilenativefoundation/store6/room/RoomBookkeeper : org/mobilenativefoundation/store6/core/seam/Bookkeeper { + public fun (Landroidx/room3/RoomDatabase;Lorg/mobilenativefoundation/store6/room/Store6BookkeeperDao;)V + public fun advanceGlobalStaleWatermark (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun advanceStaleWatermark (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun forget (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun forgetAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun forgetNamespace (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun markStale (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun recordFailure (Lorg/mobilenativefoundation/store6/core/StoreKey;JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun recordSuccess (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/StoreMeta;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun status (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class org/mobilenativefoundation/store6/room/RoomSourceOfTruth : org/mobilenativefoundation/store6/core/seam/TransactionalSourceOfTruth { + public fun (Landroidx/room3/RoomDatabase;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)V + public fun delete (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteNamespace (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun reader (Lorg/mobilenativefoundation/store6/core/StoreKey;)Lkotlinx/coroutines/flow/Flow; + public fun withTransaction (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun write (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class org/mobilenativefoundation/store6/room/Store6BookkeeperDao { + public abstract fun deleteAllRecords (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteNamespaceRecords (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteRecord (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun record (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun upsertRecord (Lorg/mobilenativefoundation/store6/room/Store6BookkeepingEntity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun upsertWatermark (Lorg/mobilenativefoundation/store6/room/Store6WatermarkEntity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun watermark (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class org/mobilenativefoundation/store6/room/Store6BookkeepingEntity { + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;ILjava/lang/Long;)V + public final fun getCanonicalId ()Ljava/lang/String; + public final fun getConsecutiveFailures ()I + public final fun getEtag ()Ljava/lang/String; + public final fun getLastFailureAtEpochMillis ()Ljava/lang/Long; + public final fun getLastSuccessSequence ()Ljava/lang/Long; + public final fun getNamespace ()Ljava/lang/String; + public final fun getStaleSequence ()Ljava/lang/Long; + public final fun getWrittenAtEpochMillis ()Ljava/lang/Long; +} + +public final class org/mobilenativefoundation/store6/room/Store6RoomSchema { + public static final field CREATE_BOOKKEEPING_TABLE Ljava/lang/String; + public static final field CREATE_WATERMARKS_TABLE Ljava/lang/String; + public static final field INSTANCE Lorg/mobilenativefoundation/store6/room/Store6RoomSchema; + public final fun createTables (Landroidx/sqlite/SQLiteConnection;)V +} + +public final class org/mobilenativefoundation/store6/room/Store6WatermarkEntity { + public fun (Ljava/lang/String;J)V + public final fun getScope ()Ljava/lang/String; + public final fun getSequence ()J +} + diff --git a/store6-room/api/store6-room.klib.api b/store6-room/api/store6-room.klib.api new file mode 100644 index 000000000..0fbaad2a7 --- /dev/null +++ b/store6-room/api/store6-room.klib.api @@ -0,0 +1,81 @@ +// Klib ABI Dump +// Targets: [iosArm64, iosSimulatorArm64, linuxX64, macosArm64, tvosArm64, watchosArm64] +// Rendering settings: +// - Signature version: 2 +// - Show manifest properties: true +// - Show declarations: true + +// Library unique name: +abstract interface org.mobilenativefoundation.store6.room/Store6BookkeeperDao { // org.mobilenativefoundation.store6.room/Store6BookkeeperDao|null[0] + abstract suspend fun deleteAllRecords() // org.mobilenativefoundation.store6.room/Store6BookkeeperDao.deleteAllRecords|deleteAllRecords(){}[0] + abstract suspend fun deleteNamespaceRecords(kotlin/String) // org.mobilenativefoundation.store6.room/Store6BookkeeperDao.deleteNamespaceRecords|deleteNamespaceRecords(kotlin.String){}[0] + abstract suspend fun deleteRecord(kotlin/String, kotlin/String) // org.mobilenativefoundation.store6.room/Store6BookkeeperDao.deleteRecord|deleteRecord(kotlin.String;kotlin.String){}[0] + abstract suspend fun record(kotlin/String, kotlin/String): org.mobilenativefoundation.store6.room/Store6BookkeepingEntity? // org.mobilenativefoundation.store6.room/Store6BookkeeperDao.record|record(kotlin.String;kotlin.String){}[0] + abstract suspend fun upsertRecord(org.mobilenativefoundation.store6.room/Store6BookkeepingEntity) // org.mobilenativefoundation.store6.room/Store6BookkeeperDao.upsertRecord|upsertRecord(org.mobilenativefoundation.store6.room.Store6BookkeepingEntity){}[0] + abstract suspend fun upsertWatermark(org.mobilenativefoundation.store6.room/Store6WatermarkEntity) // org.mobilenativefoundation.store6.room/Store6BookkeeperDao.upsertWatermark|upsertWatermark(org.mobilenativefoundation.store6.room.Store6WatermarkEntity){}[0] + abstract suspend fun watermark(kotlin/String): kotlin/Long? // org.mobilenativefoundation.store6.room/Store6BookkeeperDao.watermark|watermark(kotlin.String){}[0] +} + +final class <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> org.mobilenativefoundation.store6.room/RoomSourceOfTruth : org.mobilenativefoundation.store6.core.seam/TransactionalSourceOfTruth<#A, #B> { // org.mobilenativefoundation.store6.room/RoomSourceOfTruth|null[0] + constructor (androidx.room3/RoomDatabase, kotlin/Function1<#A, kotlinx.coroutines.flow/Flow<#B?>>, kotlin.coroutines/SuspendFunction2<#A, #B, kotlin/Unit>, kotlin.coroutines/SuspendFunction1<#A, kotlin/Unit>, kotlin.coroutines/SuspendFunction1, kotlin.coroutines/SuspendFunction0) // org.mobilenativefoundation.store6.room/RoomSourceOfTruth.|(androidx.room3.RoomDatabase;kotlin.Function1<1:0,kotlinx.coroutines.flow.Flow<1:1?>>;kotlin.coroutines.SuspendFunction2<1:0,1:1,kotlin.Unit>;kotlin.coroutines.SuspendFunction1<1:0,kotlin.Unit>;kotlin.coroutines.SuspendFunction1;kotlin.coroutines.SuspendFunction0){}[0] + + final fun reader(#A): kotlinx.coroutines.flow/Flow<#B?> // org.mobilenativefoundation.store6.room/RoomSourceOfTruth.reader|reader(1:0){}[0] + final suspend fun <#A1: kotlin/Any?> withTransaction(kotlin.coroutines/SuspendFunction0<#A1>): #A1 // org.mobilenativefoundation.store6.room/RoomSourceOfTruth.withTransaction|withTransaction(kotlin.coroutines.SuspendFunction0<0:0>){0§}[0] + final suspend fun delete(#A) // org.mobilenativefoundation.store6.room/RoomSourceOfTruth.delete|delete(1:0){}[0] + final suspend fun deleteAll() // org.mobilenativefoundation.store6.room/RoomSourceOfTruth.deleteAll|deleteAll(){}[0] + final suspend fun deleteNamespace(org.mobilenativefoundation.store6.core/StoreNamespace) // org.mobilenativefoundation.store6.room/RoomSourceOfTruth.deleteNamespace|deleteNamespace(org.mobilenativefoundation.store6.core.StoreNamespace){}[0] + final suspend fun write(#A, #B) // org.mobilenativefoundation.store6.room/RoomSourceOfTruth.write|write(1:0;1:1){}[0] +} + +final class org.mobilenativefoundation.store6.room/RoomBookkeeper : org.mobilenativefoundation.store6.core.seam/Bookkeeper { // org.mobilenativefoundation.store6.room/RoomBookkeeper|null[0] + constructor (androidx.room3/RoomDatabase, org.mobilenativefoundation.store6.room/Store6BookkeeperDao) // org.mobilenativefoundation.store6.room/RoomBookkeeper.|(androidx.room3.RoomDatabase;org.mobilenativefoundation.store6.room.Store6BookkeeperDao){}[0] + + final suspend fun advanceGlobalStaleWatermark() // org.mobilenativefoundation.store6.room/RoomBookkeeper.advanceGlobalStaleWatermark|advanceGlobalStaleWatermark(){}[0] + final suspend fun advanceStaleWatermark(org.mobilenativefoundation.store6.core/StoreNamespace) // org.mobilenativefoundation.store6.room/RoomBookkeeper.advanceStaleWatermark|advanceStaleWatermark(org.mobilenativefoundation.store6.core.StoreNamespace){}[0] + final suspend fun forget(org.mobilenativefoundation.store6.core/StoreKey) // org.mobilenativefoundation.store6.room/RoomBookkeeper.forget|forget(org.mobilenativefoundation.store6.core.StoreKey){}[0] + final suspend fun forgetAll() // org.mobilenativefoundation.store6.room/RoomBookkeeper.forgetAll|forgetAll(){}[0] + final suspend fun forgetNamespace(org.mobilenativefoundation.store6.core/StoreNamespace) // org.mobilenativefoundation.store6.room/RoomBookkeeper.forgetNamespace|forgetNamespace(org.mobilenativefoundation.store6.core.StoreNamespace){}[0] + final suspend fun markStale(org.mobilenativefoundation.store6.core/StoreKey) // org.mobilenativefoundation.store6.room/RoomBookkeeper.markStale|markStale(org.mobilenativefoundation.store6.core.StoreKey){}[0] + final suspend fun recordFailure(org.mobilenativefoundation.store6.core/StoreKey, kotlin/Long) // org.mobilenativefoundation.store6.room/RoomBookkeeper.recordFailure|recordFailure(org.mobilenativefoundation.store6.core.StoreKey;kotlin.Long){}[0] + final suspend fun recordSuccess(org.mobilenativefoundation.store6.core/StoreKey, org.mobilenativefoundation.store6.core/StoreMeta) // org.mobilenativefoundation.store6.room/RoomBookkeeper.recordSuccess|recordSuccess(org.mobilenativefoundation.store6.core.StoreKey;org.mobilenativefoundation.store6.core.StoreMeta){}[0] + final suspend fun status(org.mobilenativefoundation.store6.core/StoreKey): org.mobilenativefoundation.store6.core.seam/KeyStatus? // org.mobilenativefoundation.store6.room/RoomBookkeeper.status|status(org.mobilenativefoundation.store6.core.StoreKey){}[0] +} + +final class org.mobilenativefoundation.store6.room/Store6BookkeepingEntity { // org.mobilenativefoundation.store6.room/Store6BookkeepingEntity|null[0] + constructor (kotlin/String, kotlin/String, kotlin/Long?, kotlin/String?, kotlin/Long?, kotlin/Long?, kotlin/Int, kotlin/Long?) // org.mobilenativefoundation.store6.room/Store6BookkeepingEntity.|(kotlin.String;kotlin.String;kotlin.Long?;kotlin.String?;kotlin.Long?;kotlin.Long?;kotlin.Int;kotlin.Long?){}[0] + + final val canonicalId // org.mobilenativefoundation.store6.room/Store6BookkeepingEntity.canonicalId|{}canonicalId[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.room/Store6BookkeepingEntity.canonicalId.|(){}[0] + final val consecutiveFailures // org.mobilenativefoundation.store6.room/Store6BookkeepingEntity.consecutiveFailures|{}consecutiveFailures[0] + final fun (): kotlin/Int // org.mobilenativefoundation.store6.room/Store6BookkeepingEntity.consecutiveFailures.|(){}[0] + final val etag // org.mobilenativefoundation.store6.room/Store6BookkeepingEntity.etag|{}etag[0] + final fun (): kotlin/String? // org.mobilenativefoundation.store6.room/Store6BookkeepingEntity.etag.|(){}[0] + final val lastFailureAtEpochMillis // org.mobilenativefoundation.store6.room/Store6BookkeepingEntity.lastFailureAtEpochMillis|{}lastFailureAtEpochMillis[0] + final fun (): kotlin/Long? // org.mobilenativefoundation.store6.room/Store6BookkeepingEntity.lastFailureAtEpochMillis.|(){}[0] + final val lastSuccessSequence // org.mobilenativefoundation.store6.room/Store6BookkeepingEntity.lastSuccessSequence|{}lastSuccessSequence[0] + final fun (): kotlin/Long? // org.mobilenativefoundation.store6.room/Store6BookkeepingEntity.lastSuccessSequence.|(){}[0] + final val namespace // org.mobilenativefoundation.store6.room/Store6BookkeepingEntity.namespace|{}namespace[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.room/Store6BookkeepingEntity.namespace.|(){}[0] + final val staleSequence // org.mobilenativefoundation.store6.room/Store6BookkeepingEntity.staleSequence|{}staleSequence[0] + final fun (): kotlin/Long? // org.mobilenativefoundation.store6.room/Store6BookkeepingEntity.staleSequence.|(){}[0] + final val writtenAtEpochMillis // org.mobilenativefoundation.store6.room/Store6BookkeepingEntity.writtenAtEpochMillis|{}writtenAtEpochMillis[0] + final fun (): kotlin/Long? // org.mobilenativefoundation.store6.room/Store6BookkeepingEntity.writtenAtEpochMillis.|(){}[0] +} + +final class org.mobilenativefoundation.store6.room/Store6WatermarkEntity { // org.mobilenativefoundation.store6.room/Store6WatermarkEntity|null[0] + constructor (kotlin/String, kotlin/Long) // org.mobilenativefoundation.store6.room/Store6WatermarkEntity.|(kotlin.String;kotlin.Long){}[0] + + final val scope // org.mobilenativefoundation.store6.room/Store6WatermarkEntity.scope|{}scope[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.room/Store6WatermarkEntity.scope.|(){}[0] + final val sequence // org.mobilenativefoundation.store6.room/Store6WatermarkEntity.sequence|{}sequence[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.room/Store6WatermarkEntity.sequence.|(){}[0] +} + +final object org.mobilenativefoundation.store6.room/Store6RoomSchema { // org.mobilenativefoundation.store6.room/Store6RoomSchema|null[0] + final const val CREATE_BOOKKEEPING_TABLE // org.mobilenativefoundation.store6.room/Store6RoomSchema.CREATE_BOOKKEEPING_TABLE|{}CREATE_BOOKKEEPING_TABLE[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.room/Store6RoomSchema.CREATE_BOOKKEEPING_TABLE.|(){}[0] + final const val CREATE_WATERMARKS_TABLE // org.mobilenativefoundation.store6.room/Store6RoomSchema.CREATE_WATERMARKS_TABLE|{}CREATE_WATERMARKS_TABLE[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.room/Store6RoomSchema.CREATE_WATERMARKS_TABLE.|(){}[0] + + final fun createTables(androidx.sqlite/SQLiteConnection) // org.mobilenativefoundation.store6.room/Store6RoomSchema.createTables|createTables(androidx.sqlite.SQLiteConnection){}[0] +} diff --git a/store6-room/build.gradle.kts b/store6-room/build.gradle.kts new file mode 100644 index 000000000..6a3e62219 --- /dev/null +++ b/store6-room/build.gradle.kts @@ -0,0 +1,87 @@ +plugins { + id("org.mobilenativefoundation.store.store6.multiplatform.subset") + alias(libs.plugins.ksp) + alias(libs.plugins.room3) +} + +kotlin { + // EXACT Room-3.0.0-supported subset. room3 publishes no iosX64 variant, and no js/wasmJs/ + // mingwX64 artifacts for this module's scope; androidTarget() is mandatory under the subset + // plugin. js/wasmJs are Room-3-supported and tracked as a separate follow-up issue. + androidTarget() + jvm() + + iosArm64() + iosSimulatorArm64() + macosArm64() + watchosArm64() + tvosArm64() + + linuxX64() + + sourceSets { + val commonMain by getting { + dependencies { + api(projects.store6Core) + api(libs.room3.runtime) + } + } + val commonTest by getting + val hostTest by creating { + dependsOn(commonTest) + dependencies { + implementation(projects.store6Testing) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.turbine) + implementation(libs.androidx.sqlite.bundled) + } + } + val jvmTest by getting { + dependsOn(hostTest) + } + val nativeTest by getting { + dependsOn(hostTest) + } + listOf( + "jvmTest", + "iosArm64Test", + "iosSimulatorArm64Test", + "macosArm64Test", + "watchosArm64Test", + "tvosArm64Test", + "linuxX64Test", + ).forEach { sourceSetName -> + getByName(sourceSetName) + .languageSettings + .optIn("org.mobilenativefoundation.store6.core.ExperimentalStoreApi") + } + } +} + +dependencies { + // Room codegen runs only for test compilations: main ships annotations-only entities/DAO; + // the @Database classes live in test fixtures (and in the user's app at consumption time). + listOf( + "kspJvmTest", + "kspIosArm64Test", + "kspIosSimulatorArm64Test", + "kspMacosArm64Test", + "kspWatchosArm64Test", + "kspTvosArm64Test", + "kspLinuxX64Test", + ).forEach { configuration -> + add(configuration, libs.room3.compiler) + } +} + +// Room 3's Gradle plugin registers its extension as `room3`, not Room 2's `room` +// (androidx.room3.gradle.RoomGradlePlugin creates it under that name). Every @Database in this +// repo sets exportSchema = false — adapter main code ships entities/DAO only, no @Database — so +// no schema JSON is generated; the directory is declared to satisfy the plugin's contract. +room3 { + schemaDirectory("$projectDir/schemas") +} + +android { + namespace = "org.mobilenativefoundation.store6.room" +} diff --git a/store6-room/gradle.properties b/store6-room/gradle.properties new file mode 100644 index 000000000..2ee9bee7b --- /dev/null +++ b/store6-room/gradle.properties @@ -0,0 +1,3 @@ +VERSION_NAME=6.0.0-SNAPSHOT +POM_NAME=store6-room +POM_ARTIFACT_ID=store6-room diff --git a/store6-room/sample/build.gradle.kts b/store6-room/sample/build.gradle.kts new file mode 100644 index 000000000..5724bee1e --- /dev/null +++ b/store6-room/sample/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + id("org.jetbrains.kotlin.jvm") + alias(libs.plugins.ksp) + application +} + +kotlin { + jvmToolchain(11) + sourceSets { + getByName("main") + .languageSettings + .optIn("org.mobilenativefoundation.store6.core.ExperimentalStoreApi") + } +} + +dependencies { + implementation(projects.store6Core) + implementation(projects.store6Room) + implementation(libs.room3.runtime) + implementation(libs.androidx.sqlite.bundled) + ksp(libs.room3.compiler) +} + +application { mainClass.set("org.mobilenativefoundation.store6.room.sample.MainKt") } diff --git a/store6-room/sample/src/main/kotlin/org/mobilenativefoundation/store6/room/sample/Main.kt b/store6-room/sample/src/main/kotlin/org/mobilenativefoundation/store6/room/sample/Main.kt new file mode 100644 index 000000000..b4221770b --- /dev/null +++ b/store6-room/sample/src/main/kotlin/org/mobilenativefoundation/store6/room/sample/Main.kt @@ -0,0 +1,310 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.room.sample + +import androidx.room3.Dao +import androidx.room3.Database +import androidx.room3.Entity +import androidx.room3.PrimaryKey +import androidx.room3.Query +import androidx.room3.Room +import androidx.room3.RoomDatabase +import androidx.room3.Upsert +import androidx.room3.migration.Migration +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.store +import org.mobilenativefoundation.store6.room.RoomBookkeeper +import org.mobilenativefoundation.store6.room.RoomSourceOfTruth +import org.mobilenativefoundation.store6.room.Store6BookkeeperDao +import org.mobilenativefoundation.store6.room.Store6BookkeepingEntity +import org.mobilenativefoundation.store6.room.Store6RoomSchema +import org.mobilenativefoundation.store6.room.Store6WatermarkEntity + +private val usersNamespace = StoreNamespace("users") + +@Entity(tableName = "users") +internal data class UserEntity( + @PrimaryKey + val id: String, + val name: String, +) + +@Dao +internal interface UserDao { + @Query("SELECT * FROM users WHERE id = :id") + fun user(id: String): Flow + + @Upsert + suspend fun upsert(user: UserEntity) + + @Query("DELETE FROM users WHERE id = :id") + suspend fun delete(id: String) + + @Query("DELETE FROM users") + suspend fun deleteAll() +} + +@Database( + entities = [UserEntity::class], + version = 1, + exportSchema = false, +) +internal abstract class LegacyDatabase : RoomDatabase() { + abstract fun userDao(): UserDao +} + +@Database( + entities = [ + UserEntity::class, + Store6BookkeepingEntity::class, + Store6WatermarkEntity::class, + ], + version = 2, + exportSchema = false, +) +internal abstract class AppDatabase : RoomDatabase() { + abstract fun userDao(): UserDao + + abstract fun store6BookkeeperDao(): Store6BookkeeperDao +} + +internal data class User( + val id: String, + val name: String, +) + +internal class UserKey( + val id: String, +) : StoreKey { + override val namespace: StoreNamespace = usersNamespace + + override fun canonicalId(): String = id +} + +private class FakeApi { + private val callsById = mutableMapOf() + + suspend fun getUser(id: String): User { + val revision = callsById.getOrElse(id) { 0 } + 1 + callsById[id] = revision + return User(id = id, name = "Fetched user $id, revision $revision") + } + + fun callsFor(id: String): Int = callsById[id] ?: 0 +} + +private val addStore6Tables = + object : Migration(1, 2) { + // Room 3's Migration.migrate is suspend; Store6RoomSchema.createTables stays non-suspend + // because androidx.sqlite 2.7.0's execSQL is still synchronous. + override suspend fun migrate(connection: SQLiteConnection) = + Store6RoomSchema.createTables(connection) + } + +public fun main(): Unit = + runBlocking { + val databaseFile = + File.createTempFile("store6-room-sample-", ".db").also { file -> + check(file.delete()) { "Could not prepare ${file.absolutePath}" } + } + println("Room walkthrough database: ${databaseFile.absolutePath}") + + try { + seedLegacyDatabase(databaseFile.absolutePath) + + val api = FakeApi() + val legacyKey = UserKey("legacy") + val remoteKey = UserKey("remote") + + println("\n1. Migrate the existing Room database and add Store") + openAppDatabase(databaseFile.absolutePath).let { database -> + val store = createStore(database, api) + try { + val legacy = + store + .stream(legacyKey, Freshness.LocalOnly) + .first { frame -> frame is StoreResult.Data } + .requireData() + check(legacy.origin == Origin.SOT) + check(api.callsFor(legacyKey.id) == 0) + println( + "legacy row: Data(name=${legacy.value.name}, " + + "origin=${legacy.origin}), fetches=0", + ) + + val coldFrames = store.stream(remoteKey).take(2).toList() + check(coldFrames.first() is StoreResult.Loading) + val fetched = coldFrames.last().requireData() + check(fetched.origin == Origin.FETCHER) + check(api.callsFor(remoteKey.id) == 1) + coldFrames.forEach { frame -> println("cold row: ${frame.describe()}") } + + store.close() + store.close() + val closedCallFailure = + runCatching { store.get(legacyKey) }.exceptionOrNull() + check( + closedCallFailure is IllegalStateException && + closedCallFailure.message == "Store is closed.", + ) + println( + "close: synchronous and idempotent; a later call failed immediately", + ) + } finally { + store.close() + database.close() + } + } + + println("\n2. Rebuild Store and Room over the same file") + openAppDatabase(databaseFile.absolutePath).let { database -> + val store = createStore(database, api) + try { + val fetchesBeforeRestart = api.callsFor(remoteKey.id) + val restarted = + store + .stream(remoteKey) + .first { frame -> frame is StoreResult.Data } + .requireData() + check(restarted.origin == Origin.SOT) + check(!restarted.isStale) + check(!restarted.refreshing) + check(api.callsFor(remoteKey.id) == fetchesBeforeRestart) + println( + "restart: Data(name=${restarted.value.name}, " + + "origin=${restarted.origin}), fetches unchanged", + ) + + store.invalidateNamespace(usersNamespace) + println("persisted namespace invalidation") + } finally { + store.close() + database.close() + } + } + + println("\n3. Rebuild again after invalidation") + openAppDatabase(databaseFile.absolutePath).let { database -> + val store = createStore(database, api) + try { + val fetchesBeforeRefresh = api.callsFor(remoteKey.id) + val refreshed = + store + .stream(remoteKey) + .onEach { frame -> + println("invalidated restart: ${frame.describe()}") + } + .first { frame -> + frame is StoreResult.Data && frame.origin == Origin.FETCHER + } + .requireData() + check(api.callsFor(remoteKey.id) == fetchesBeforeRefresh + 1) + println("refetched: ${refreshed.value.name}") + } finally { + store.close() + database.close() + } + } + + println( + "\nDone. Durability lived in the Room row and Store6 sidecar, " + + "not in a retained Store engine.", + ) + println( + "Quiescent engine residency is bounded at 128 by default. " + + "Calls after Store.close() fail immediately.", + ) + } finally { + deleteDatabaseFiles(databaseFile.absolutePath) + } + } + +private suspend fun seedLegacyDatabase(path: String) { + val database = openLegacyDatabase(path) + try { + database.userDao().upsert(UserEntity(id = "legacy", name = "Legacy user")) + println("seeded version-1 users table before Store existed") + } finally { + database.close() + } +} + +private fun openLegacyDatabase(path: String): LegacyDatabase = + configureDatabase(Room.databaseBuilder(name = path)) + +private fun openAppDatabase(path: String): AppDatabase = + configureDatabase( + Room + .databaseBuilder(name = path) + .addMigrations(addStore6Tables), + ) + +private fun configureDatabase( + builder: RoomDatabase.Builder, +): T = + builder + .setDriver(BundledSQLiteDriver()) + .setQueryCoroutineContext(Dispatchers.Default) + .build() + +private fun createStore( + database: AppDatabase, + api: FakeApi, +): Store { + val dao = database.userDao() + return store { + fetcher { key -> api.getUser(key.id) } + persistence( + RoomSourceOfTruth( + database = database, + rowReader = { key -> dao.user(key.id).map { row -> row?.toUser() } }, + rowWriter = { _, user -> dao.upsert(user.toEntity()) }, + rowDeleter = { key -> dao.delete(key.id) }, + namespaceDeleter = { _ -> dao.deleteAll() }, + allDeleter = { dao.deleteAll() }, + ), + ) + bookkeeper(RoomBookkeeper(database, database.store6BookkeeperDao())) + } +} + +private fun UserEntity.toUser(): User = User(id = id, name = name) + +private fun User.toEntity(): UserEntity = UserEntity(id = id, name = name) + +private fun StoreResult.requireData(): StoreResult.Data = + when (this) { + is StoreResult.Data -> this + else -> error("Expected Data, received ${describe()}") + } + +private fun StoreResult.describe(): String = + when (this) { + is StoreResult.Loading -> "Loading" + is StoreResult.Data -> + "Data(name=${value.name}, origin=$origin, stale=$isStale, refreshing=$refreshing)" + is StoreResult.Revalidated -> "Revalidated(age=$age)" + is StoreResult.Error -> "Error(error=$error, servedStale=$servedStale)" + } + +private fun deleteDatabaseFiles(path: String) { + listOf(path, "$path-wal", "$path-shm", "$path-journal").forEach { candidate -> + File(candidate).delete() + } +} diff --git a/store6-room/src/androidMain/AndroidManifest.xml b/store6-room/src/androidMain/AndroidManifest.xml new file mode 100644 index 000000000..8072ee00d --- /dev/null +++ b/store6-room/src/androidMain/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/RoomBookkeeper.kt b/store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/RoomBookkeeper.kt new file mode 100644 index 000000000..1f727e36f --- /dev/null +++ b/store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/RoomBookkeeper.kt @@ -0,0 +1,244 @@ +@file:OptIn(DelicateStoreApi::class) + +package org.mobilenativefoundation.store6.room + +import androidx.room3.RoomDatabase +import androidx.room3.deferredTransaction +import androidx.room3.immediateTransaction +import androidx.room3.useReaderConnection +import androidx.room3.useWriterConnection +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreMeta +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.core.seam.KeyStatus +import kotlin.coroutines.cancellation.CancellationException + +/** + * Durable Room [Bookkeeper] backed by the adapter-owned sidecar. + * + * Identity is exclusively `(namespace.value, canonicalId())`. One allocator persisted at + * `store6.sequence` supplies the durable sequence shared by successes, per-key stale marks, + * namespace watermarks, and the global watermark. A key is durably stale exactly when + * `max(mark/ns/global) > (success ?: 0)`. + * + * Room commits the user value and freshness metadata as two non-atomic durable steps. A crash + * between them, or a sidecar write failure absorbed by this seam, can leave a persisted value + * without durable freshness metadata. Rehydration conservatively treats that value as + * age-unknown/stale. + * + * [recordSuccess], [recordFailure], and [forget] absorb storage failures; [status] returns null for + * unavailable storage or invalid sidecar data. Cooperative caller cancellation always propagates. + * A Room-internal cancellation from unavailable or closed storage is classified using the active + * caller context and treated as the corresponding storage failure. Maintenance methods propagate + * storage failures and remain transaction-atomic. + */ +@ExperimentalStoreApi +public class RoomBookkeeper( + private val database: RoomDatabase, + private val dao: Store6BookkeeperDao, +) : Bookkeeper { + public override suspend fun recordSuccess( + key: StoreKey, + meta: StoreMeta, + ) { + val namespace = key.namespace.value + val canonicalId = key.canonicalId() + infallibly { + inWriteTransaction { + val sequence = nextSequence() + val previous = dao.record(namespace, canonicalId) + dao.upsertRecord( + Store6BookkeepingEntity( + namespace = namespace, + canonicalId = canonicalId, + writtenAtEpochMillis = meta.writtenAtEpochMillis, + etag = meta.etag, + lastSuccessSequence = sequence, + lastFailureAtEpochMillis = null, + consecutiveFailures = 0, + staleSequence = previous?.staleSequence, + ), + ) + } + } + } + + public override suspend fun recordFailure( + key: StoreKey, + atEpochMillis: Long, + ) { + val namespace = key.namespace.value + val canonicalId = key.canonicalId() + infallibly { + inWriteTransaction { + val previous = dao.record(namespace, canonicalId) + dao.upsertRecord( + Store6BookkeepingEntity( + namespace = namespace, + canonicalId = canonicalId, + writtenAtEpochMillis = previous?.writtenAtEpochMillis, + etag = previous?.etag, + lastSuccessSequence = previous?.lastSuccessSequence, + lastFailureAtEpochMillis = atEpochMillis, + consecutiveFailures = (previous?.consecutiveFailures ?: 0) + 1, + staleSequence = previous?.staleSequence, + ), + ) + } + } + } + + public override suspend fun status(key: StoreKey): KeyStatus? { + val namespace = key.namespace.value + val canonicalId = key.canonicalId() + return try { + database.useReaderConnection { transactor -> + transactor.deferredTransaction { + val record = dao.record(namespace, canonicalId) + val coveringSequence = + maxOf( + record?.staleSequence ?: 0L, + dao.watermark(namespaceScope(namespace)) ?: 0L, + dao.watermark(GLOBAL_SCOPE) ?: 0L, + ) + + when { + record == null && coveringSequence == 0L -> null + record == null -> + KeyStatus( + meta = null, + lastSuccessSequence = null, + lastFailureAtEpochMillis = null, + consecutiveFailures = 0, + durablyStale = true, + ) + else -> { + val successSequence = record.lastSuccessSequence + KeyStatus( + meta = + successSequence?.let { + RoomStoreMeta( + writtenAtEpochMillis = + checkNotNull(record.writtenAtEpochMillis) { + "Room bookkeeping success is missing its " + + "written timestamp" + }, + etag = record.etag, + ) + }, + lastSuccessSequence = successSequence, + lastFailureAtEpochMillis = + record.lastFailureAtEpochMillis, + consecutiveFailures = record.consecutiveFailures, + durablyStale = + coveringSequence > (successSequence ?: 0L), + ) + } + } + } + } + } catch (_: CancellationException) { + currentCoroutineContext().ensureActive() + null + } catch (_: Throwable) { + null + } + } + + public override suspend fun forget(key: StoreKey) { + val namespace = key.namespace.value + val canonicalId = key.canonicalId() + infallibly { + dao.deleteRecord(namespace, canonicalId) + } + } + + public override suspend fun markStale(key: StoreKey) { + val namespace = key.namespace.value + val canonicalId = key.canonicalId() + inWriteTransaction { + val sequence = nextSequence() + val previous = dao.record(namespace, canonicalId) + dao.upsertRecord( + Store6BookkeepingEntity( + namespace = namespace, + canonicalId = canonicalId, + writtenAtEpochMillis = previous?.writtenAtEpochMillis, + etag = previous?.etag, + lastSuccessSequence = previous?.lastSuccessSequence, + lastFailureAtEpochMillis = previous?.lastFailureAtEpochMillis, + consecutiveFailures = previous?.consecutiveFailures ?: 0, + staleSequence = sequence, + ), + ) + } + } + + public override suspend fun advanceStaleWatermark(namespace: StoreNamespace) { + val namespaceValue = namespace.value + inWriteTransaction { + advanceWatermark(namespaceScope(namespaceValue)) + } + } + + public override suspend fun advanceGlobalStaleWatermark() { + inWriteTransaction { + advanceWatermark(GLOBAL_SCOPE) + } + } + + public override suspend fun forgetNamespace(namespace: StoreNamespace) { + val namespaceValue = namespace.value + inWriteTransaction { + dao.deleteNamespaceRecords(namespaceValue) + } + } + + public override suspend fun forgetAll() { + inWriteTransaction { + dao.deleteAllRecords() + } + } + + private suspend fun nextSequence(): Long { + val current = dao.watermark(SEQUENCE_SCOPE) ?: 0L + check(current < Long.MAX_VALUE) { "Bookkeeper sequence exhausted" } + val next = current + 1L + dao.upsertWatermark(Store6WatermarkEntity(SEQUENCE_SCOPE, next)) + return next + } + + private suspend fun advanceWatermark(scope: String) { + val sequence = nextSequence() + dao.upsertWatermark(Store6WatermarkEntity(scope, sequence)) + } + + private suspend fun inWriteTransaction(block: suspend () -> T): T = + database.useWriterConnection { transactor -> + transactor.immediateTransaction { + block() + } + } + + private suspend inline fun infallibly(crossinline block: suspend () -> Unit) { + try { + block() + } catch (_: CancellationException) { + currentCoroutineContext().ensureActive() + } catch (_: Throwable) { + // The Bookkeeper contract makes operational writes infallible. + } + } + + private companion object { + private const val SEQUENCE_SCOPE = "store6.sequence" + private const val GLOBAL_SCOPE = "store6.global" + + fun namespaceScope(namespace: String): String = "ns:$namespace" + } +} diff --git a/store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/RoomSourceOfTruth.kt b/store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/RoomSourceOfTruth.kt new file mode 100644 index 000000000..a678a5e69 --- /dev/null +++ b/store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/RoomSourceOfTruth.kt @@ -0,0 +1,628 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.DelicateStoreApi::class) + +package org.mobilenativefoundation.store6.room + +import androidx.room3.RoomDatabase +import androidx.room3.immediateTransaction +import androidx.room3.useWriterConnection +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onSubscription +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.seam.TransactionalSourceOfTruth +import kotlin.coroutines.CoroutineContext + +private const val DATABASE_ADMISSION_STRIPE_COUNT: Int = 64 +private const val CHILD_TRANSACTION_MUTATION_MESSAGE: String = + "Room transaction mutations must remain sequential in the owning coroutine" + +/** Bounded process-wide coordination. Hash collisions only over-serialize unrelated databases. */ +private object RoomDatabaseAdmissionCoordinator { + private val stripes: List = + List(DATABASE_ADMISSION_STRIPE_COUNT) { Mutex() } + + fun stripeIndex(database: RoomDatabase): Int = + (database.hashCode() and Int.MAX_VALUE) % DATABASE_ADMISSION_STRIPE_COUNT + + fun stripe(index: Int): Mutex = stripes[index] +} + +/** + * A coroutine-local frame for one held coordinator stripe. + * + * The frame deliberately stores no database. [ownerJob] lets a launched child fail before waiting + * on its parent's stripe, while [parent] lets one coroutine nest operations on other stripes. + */ +private class RoomDatabaseAdmissionFrame( + val stripeIndex: Int, + var ownerJob: Job?, + val token: Any, + val parent: RoomDatabaseAdmissionFrame?, +) : CoroutineContext.Element { + companion object Key : CoroutineContext.Key + + override val key: CoroutineContext.Key<*> = Key +} + +/** Type-erased delivery for one registered reader set in an outer Room transaction. */ +private class PendingRoomEcho( + val identity: Any, + val generation: Long, + val publishAt: suspend (Long) -> Unit, + val settleAt: suspend (Long) -> Unit, +) + +/** + * Buffers mutation echoes until the outer Room transaction resolves. + * + * Per registration, `highestGeneration` covers every allocation while `finalPublisher` represents + * only the last successful nested mutation. Commit publishes that final value once at the highest + * generation; rollback settles the same high-water mark silently. + */ +private class RoomEchoTransactionFrame( + val database: RoomDatabase, + val admissionToken: Any, + val parent: RoomEchoTransactionFrame?, +) : CoroutineContext.Element { + companion object Key : CoroutineContext.Key + + override val key: CoroutineContext.Key<*> = Key + + private class Entry( + var highestGeneration: Long, + var settleAt: suspend (Long) -> Unit, + var finalPublisher: (suspend (Long) -> Unit)? = null, + ) + + private val entries = LinkedHashMap() + + fun track(pending: List) { + pending.forEach { echo -> + val entry = entries[echo.identity] + if (entry == null) { + entries[echo.identity] = + Entry( + highestGeneration = echo.generation, + settleAt = echo.settleAt, + ) + } else { + entry.highestGeneration = echo.generation + entry.settleAt = echo.settleAt + } + } + } + + fun markSuccessful(pending: List) { + pending.forEach { echo -> + entries.getValue(echo.identity).finalPublisher = echo.publishAt + } + } + + fun mergeCommitted(child: RoomEchoTransactionFrame) { + child.entries.forEach { (identity, childEntry) -> + val entry = entries[identity] + if (entry == null) { + entries[identity] = + Entry( + highestGeneration = childEntry.highestGeneration, + settleAt = childEntry.settleAt, + finalPublisher = childEntry.finalPublisher, + ) + } else { + entry.highestGeneration = childEntry.highestGeneration + entry.settleAt = childEntry.settleAt + if (childEntry.finalPublisher != null) { + entry.finalPublisher = childEntry.finalPublisher + } + } + } + } + + fun mergeRolledBack(child: RoomEchoTransactionFrame) { + child.entries.forEach { (identity, childEntry) -> + val entry = entries[identity] + if (entry == null) { + entries[identity] = + Entry( + highestGeneration = childEntry.highestGeneration, + settleAt = childEntry.settleAt, + ) + } else { + entry.highestGeneration = childEntry.highestGeneration + entry.settleAt = childEntry.settleAt + } + } + } + + suspend fun publishCommit() { + entries.values.forEach { entry -> + val publisher = entry.finalPublisher + if (publisher == null) { + entry.settleAt(entry.highestGeneration) + } else { + publisher(entry.highestGeneration) + } + } + } + + suspend fun settleRollback() { + entries.values.forEach { entry -> + entry.settleAt(entry.highestGeneration) + } + } +} + +/** + * [TransactionalSourceOfTruth] over user DAO lambdas backed by one [RoomDatabase]. + * + * Reader semantics on Room's table-granular InvalidationTracker use a generation-gated echo. + * Every mutation through this instance allocates a monotonic per-key generation before its + * transaction commits, then publishes either a generation-stamped echo after commit or a silent + * settlement after rollback. Generations are never reused. Each collection captures a settlement + * baseline when its echo subscription is established, always permits its first database snapshot, + * drops later database re-emissions while an allocated generation is unconsumed, equality- + * suppresses database signals otherwise, and emits every committed echo newer than its baseline. + * + * This makes equal-value rewrites through this instance re-emit exactly once and prevents writes + * to another row in the same table from re-emitting an unchanged value. Equality is structural on + * [V]; identity-equality values can therefore surface duplicate unchanged-row emissions after + * same-table writes. Those duplicates are safe for the engine's conflation but noisier, so value + * types are preferred. Echo publication uses a bounded suspending buffer: a starved collector + * backpressures writers instead of dropping a mutation or leaving the generation gate open. Once + * database admission is acquired, the root transaction and its commit-critical echo or rollback + * settlement run under [NonCancellable]. Caller cancellation before admission remains cancellable; + * after admission it cannot turn a durable commit into a boundary throw. A `CancellationException` + * explicitly thrown by the mutation still rolls the transaction back and propagates. + * + * External changes through other database handles surface from the Room query while collected + * and in every new collection's first emission. An external change racing a mutation through this + * instance on the same key can be coalesced into that mutation's echo or the next re-query. One + * theoretical stall window remains: a query can snapshot before a mutation, then delay delivery + * until after that mutation's echo is consumed, surfacing one stale emission before the mutation's + * own invalidation re-query self-heals it. If observed, the escalation is a per-key version stamp + * written in the transaction and joined into the reader query. + * + * Top-level mutations and [withTransaction] acquire database-scoped mutation admission before a + * Room writer. A bounded global hash stripe coordinates distinct adapter instances without + * retaining databases. A coroutine-context frame makes same-Job, same-database nesting reentrant; + * a launched child that tries to mutate on an inherited stripe fails immediately instead of + * deadlocking its structured parent. Transaction blocks must therefore keep Room mutations + * sequential in their owning coroutine. Queued top-level mutations wait without retaining a Room + * writer. + * + * When [withTransaction] wraps nested writes, nested mutations enlist in the outer transaction + * and remain invisible to readers until the outer transaction commits. A rollback publishes no + * value. Multiple same-key writes in one outer transaction coalesce to its final committed value, + * so readers never observe a transient nested value that did not exist outside the transaction. + */ +@ExperimentalStoreApi +public class RoomSourceOfTruth( + private val database: RoomDatabase, + private val rowReader: (K) -> Flow, + private val rowWriter: suspend (K, V) -> Unit, + private val rowDeleter: suspend (K) -> Unit, + private val namespaceDeleter: suspend (StoreNamespace) -> Unit, + private val allDeleter: suspend () -> Unit, +) : TransactionalSourceOfTruth { + private sealed interface Signal { + /** Prepended at echo-subscription time; carries the generation floor for a collection. */ + class Baseline( + val generation: Long, + ) : Signal + + class FromMutation( + val generation: Long, + val value: V?, + ) : Signal + + /** Advances reader-local generation state without publishing a value. */ + class Settled( + val generation: Long, + ) : Signal + + class FromDatabase( + val value: V?, + ) : Signal + } + + private class Registration { + var activeReaders: Int = 0 + + /** + * Highest mutation generation allocated for this key. Written under database admission + * before the transaction commits and never decremented; collectors read it through + * StateFlow volatile semantics. + */ + val allocatedGeneration: MutableStateFlow = MutableStateFlow(0L) + + /** Highest allocation resolved by either a committed echo or a silent rollback. */ + val settledGeneration: MutableStateFlow = MutableStateFlow(0L) + + /** Publishes mutation echoes; [Signal.Baseline] is prepended at subscription time. */ + val mutationEchoes: MutableSharedFlow> = + MutableSharedFlow( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.SUSPEND, + ) + } + + private val registrationsLock = Mutex() + private val registrations = HashMap, Registration>() + + override fun reader(key: K): Flow = + flow { + val id = idOf(key) + val registration = register(id) + try { + var emittedAny = false + var lastEmitted: V? = null + // -1 means the echo subscription has not established its baseline yet. + var lastSeenGeneration = -1L + merge( + registration.mutationEchoes.onSubscription { + emit(Signal.Baseline(registration.settledGeneration.value)) + }, + rowReader(key).map { value -> Signal.FromDatabase(value) }, + ).collect { signal -> + when (signal) { + is Signal.Baseline -> + if (signal.generation > lastSeenGeneration) { + lastSeenGeneration = signal.generation + } + is Signal.FromMutation -> + if (signal.generation > lastSeenGeneration) { + lastSeenGeneration = signal.generation + emittedAny = true + lastEmitted = signal.value + emit(signal.value) + } + is Signal.Settled -> + if (signal.generation > lastSeenGeneration) { + lastSeenGeneration = signal.generation + } + is Signal.FromDatabase -> { + val echoInFlight = + registration.allocatedGeneration.value > lastSeenGeneration + if (!emittedAny || (!echoInFlight && signal.value != lastEmitted)) { + emittedAny = true + lastEmitted = signal.value + emit(signal.value) + } + } + } + } + } finally { + withContext(NonCancellable) { unregister(id) } + } + } + + override suspend fun write( + key: K, + value: V, + ) { + mutateAndEcho( + targets = { + registrationsLock.withLock { + listOfNotNull(registrations[idOf(key)]) + } + }, + echoedValue = value, + ) { + rowWriter(key, value) + } + } + + override suspend fun delete(key: K) { + mutateAndEcho( + targets = { + registrationsLock.withLock { + listOfNotNull(registrations[idOf(key)]) + } + }, + echoedValue = null, + ) { + rowDeleter(key) + } + } + + override suspend fun deleteNamespace(namespace: StoreNamespace) { + mutateAndEcho( + targets = { + registrationsLock.withLock { + registrations + .filterKeys { id -> id.first == namespace.value } + .values + .toList() + } + }, + echoedValue = null, + ) { + namespaceDeleter(namespace) + } + } + + override suspend fun deleteAll() { + mutateAndEcho( + targets = { + registrationsLock.withLock { + registrations.values.toList() + } + }, + echoedValue = null, + ) { + allDeleter() + } + } + + override suspend fun withTransaction(block: suspend () -> R): R = + withDatabaseAdmission { admissionToken -> + val existing = transactionFrame(admissionToken) + if (existing == null) { + runRootTransaction(admissionToken) { block() } + } else { + runNestedTransaction(existing, admissionToken, block) + } + } + + /** + * Acquires admission before a top-level writer, commits, releases that writer, and publishes + * ordered echoes while retaining admission. Nested calls buffer against the outer transaction. + */ + private suspend fun mutateAndEcho( + targets: suspend () -> List>, + echoedValue: V?, + block: suspend () -> Unit, + ) { + withDatabaseAdmission { admissionToken -> + val existing = transactionFrame(admissionToken) + if (existing == null) { + runRootTransaction(admissionToken) { + mutateWithinTransaction( + transaction = checkNotNull(transactionFrame(admissionToken)), + admissionToken = admissionToken, + targets = targets, + echoedValue = echoedValue, + nested = false, + block = block, + ) + } + } else { + mutateWithinTransaction( + transaction = existing, + admissionToken = admissionToken, + targets = targets, + echoedValue = echoedValue, + nested = true, + block = block, + ) + } + } + } + + /** + * Allocates monotonic generations and attaches their final outcome to the outer transaction. + */ + private suspend fun mutateWithinTransaction( + transaction: RoomEchoTransactionFrame, + admissionToken: Any, + targets: suspend () -> List>, + echoedValue: V?, + nested: Boolean, + block: suspend () -> Unit, + ) { + val pending = + targets().map { registration -> + val generation = registration.allocatedGeneration.value + 1L + registration.allocatedGeneration.value = generation + PendingRoomEcho( + identity = registration, + generation = generation, + publishAt = { publishedGeneration -> + registration.settledGeneration.value = publishedGeneration + registration.mutationEchoes.emit( + Signal.FromMutation(publishedGeneration, echoedValue), + ) + }, + settleAt = { settledGeneration -> + registration.settledGeneration.value = settledGeneration + registration.mutationEchoes.emit( + Signal.Settled(settledGeneration), + ) + }, + ) + } + transaction.track(pending) + + if (nested) { + runInTransaction(admissionToken, block) + } else { + block() + } + transaction.markSuccessful(pending) + } + + /** + * Runs one outer Room transaction, then resolves every buffered generation while admission is + * still held. The admitted transaction and its resolution are cancellation-shielded. + */ + private suspend fun runRootTransaction( + admissionToken: Any, + block: suspend () -> R, + ): R { + val frame = + RoomEchoTransactionFrame( + database = database, + admissionToken = admissionToken, + parent = currentCoroutineContext()[RoomEchoTransactionFrame], + ) + return withContext(NonCancellable + frame) { + try { + val result = runInTransaction(admissionToken, block) + frame.publishCommit() + result + } catch (failure: Throwable) { + try { + frame.settleRollback() + } catch (settlementFailure: Throwable) { + failure.addSuppressed(settlementFailure) + } + throw failure + } + } + } + + /** + * Gives explicit nested transactions their own echo frame, mirroring Room's savepoint. + */ + private suspend fun runNestedTransaction( + parent: RoomEchoTransactionFrame, + admissionToken: Any, + block: suspend () -> R, + ): R { + val child = + RoomEchoTransactionFrame( + database = database, + admissionToken = admissionToken, + parent = parent, + ) + return try { + val result = + withContext(child) { + runInTransaction(admissionToken, block) + } + parent.mergeCommitted(child) + result + } catch (failure: Throwable) { + parent.mergeRolledBack(child) + throw failure + } + } + + /** + * Acquires the database's bounded coordinator stripe before asking Room for a writer. + * + * Reentrancy is stripe-and-owner based so a same-Job hash collision cannot self-deadlock. + * Transaction joining remains referentially database-scoped in [transactionFrame]. + */ + private suspend fun withDatabaseAdmission(block: suspend (Any) -> R): R { + val context = currentCoroutineContext() + val ownerJob = context[Job] + val stripeIndex = RoomDatabaseAdmissionCoordinator.stripeIndex(database) + val inherited = context[RoomDatabaseAdmissionFrame] + val held = inherited.findStripe(stripeIndex) + if (held != null) { + check(held.ownerJob === ownerJob) { + CHILD_TRANSACTION_MUTATION_MESSAGE + } + return block(held.token) + } + + return RoomDatabaseAdmissionCoordinator.stripe(stripeIndex).withLock { + val frame = + RoomDatabaseAdmissionFrame( + stripeIndex = stripeIndex, + ownerJob = currentCoroutineContext()[Job], + token = Any(), + parent = currentCoroutineContext()[RoomDatabaseAdmissionFrame], + ) + withContext(NonCancellable + frame) { + block(frame.token) + } + } + } + + private fun RoomDatabaseAdmissionFrame?.findStripe( + stripeIndex: Int, + ): RoomDatabaseAdmissionFrame? { + var candidate = this + while (candidate != null) { + if (candidate.stripeIndex == stripeIndex) { + return candidate + } + candidate = candidate.parent + } + return null + } + + private suspend fun runInTransaction( + admissionToken: Any, + block: suspend () -> R, + ): R = + database.useWriterConnection { transactor -> + transactor.immediateTransaction { + val context = currentCoroutineContext() + val frame = context[RoomDatabaseAdmissionFrame] + checkNotNull(frame) { "Room mutation admission is missing" } + val matchingFrame = frame.findByToken(admissionToken) + checkNotNull(matchingFrame) { "Room mutation admission token is missing" } + val ownerJob = context[Job] + val previousOwner = matchingFrame.ownerJob + matchingFrame.ownerJob = ownerJob + try { + block() + } finally { + matchingFrame.ownerJob = previousOwner + } + } + } + + private fun RoomDatabaseAdmissionFrame.findByToken( + admissionToken: Any, + ): RoomDatabaseAdmissionFrame? { + var candidate: RoomDatabaseAdmissionFrame? = this + while (candidate != null) { + if (candidate.token === admissionToken) { + return candidate + } + candidate = candidate.parent + } + return null + } + + private suspend fun transactionFrame( + admissionToken: Any, + ): RoomEchoTransactionFrame? { + var candidate = currentCoroutineContext()[RoomEchoTransactionFrame] + while (candidate != null) { + if (candidate.database === database && candidate.admissionToken === admissionToken) { + return candidate + } + candidate = candidate.parent + } + return null + } + + private fun idOf(key: K): Pair = + key.namespace.value to key.canonicalId() + + private suspend fun register(id: Pair): Registration = + registrationsLock.withLock { + registrations + .getOrPut(id) { Registration() } + .also { registration -> registration.activeReaders += 1 } + } + + private suspend fun unregister(id: Pair) { + registrationsLock.withLock { + val registration = registrations[id] ?: return@withLock + registration.activeReaders -= 1 + if (registration.activeReaders == 0) { + registrations.remove(id) + } + } + } +} diff --git a/store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/RoomStoreMeta.kt b/store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/RoomStoreMeta.kt new file mode 100644 index 000000000..cf7fac72c --- /dev/null +++ b/store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/RoomStoreMeta.kt @@ -0,0 +1,8 @@ +package org.mobilenativefoundation.store6.room + +import org.mobilenativefoundation.store6.core.StoreMeta + +internal class RoomStoreMeta( + override val writtenAtEpochMillis: Long, + override val etag: String?, +) : StoreMeta diff --git a/store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/Store6BookkeeperDao.kt b/store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/Store6BookkeeperDao.kt new file mode 100644 index 000000000..806a8f4f7 --- /dev/null +++ b/store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/Store6BookkeeperDao.kt @@ -0,0 +1,44 @@ +package org.mobilenativefoundation.store6.room + +import androidx.room3.Dao +import androidx.room3.Query +import androidx.room3.Upsert +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi + +/** Room primitives for the adapter-owned bookkeeping sidecar. */ +@ExperimentalStoreApi +@Dao +public interface Store6BookkeeperDao { + @Query( + "SELECT * FROM store6_bookkeeping " + + "WHERE namespace = :namespace AND canonical_id = :canonicalId", + ) + public suspend fun record( + namespace: String, + canonicalId: String, + ): Store6BookkeepingEntity? + + @Upsert + public suspend fun upsertRecord(record: Store6BookkeepingEntity) + + @Query( + "DELETE FROM store6_bookkeeping " + + "WHERE namespace = :namespace AND canonical_id = :canonicalId", + ) + public suspend fun deleteRecord( + namespace: String, + canonicalId: String, + ) + + @Query("DELETE FROM store6_bookkeeping WHERE namespace = :namespace") + public suspend fun deleteNamespaceRecords(namespace: String) + + @Query("DELETE FROM store6_bookkeeping") + public suspend fun deleteAllRecords() + + @Query("SELECT sequence FROM store6_watermarks WHERE scope = :scope") + public suspend fun watermark(scope: String): Long? + + @Upsert + public suspend fun upsertWatermark(watermark: Store6WatermarkEntity) +} diff --git a/store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/Store6BookkeepingEntity.kt b/store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/Store6BookkeepingEntity.kt new file mode 100644 index 000000000..d87202091 --- /dev/null +++ b/store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/Store6BookkeepingEntity.kt @@ -0,0 +1,36 @@ +package org.mobilenativefoundation.store6.room + +import androidx.room3.ColumnInfo +import androidx.room3.Entity +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi + +/** + * Adapter-owned bookkeeping sidecar. + * + * Include this entity and [Store6WatermarkEntity] in the user's Room database, add an abstract + * [Store6BookkeeperDao] accessor, and migrate existing databases with + * [Store6RoomSchema.createTables]. This sidecar requires no changes to user tables. + */ +@ExperimentalStoreApi +@Entity( + tableName = "store6_bookkeeping", + primaryKeys = ["namespace", "canonical_id"], +) +public class Store6BookkeepingEntity( + @ColumnInfo(name = "namespace") + public val namespace: String, + @ColumnInfo(name = "canonical_id") + public val canonicalId: String, + @ColumnInfo(name = "written_at_epoch_millis") + public val writtenAtEpochMillis: Long?, + @ColumnInfo(name = "etag") + public val etag: String?, + @ColumnInfo(name = "last_success_sequence") + public val lastSuccessSequence: Long?, + @ColumnInfo(name = "last_failure_at_epoch_millis") + public val lastFailureAtEpochMillis: Long?, + @ColumnInfo(name = "consecutive_failures") + public val consecutiveFailures: Int, + @ColumnInfo(name = "stale_sequence") + public val staleSequence: Long?, +) diff --git a/store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/Store6RoomSchema.kt b/store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/Store6RoomSchema.kt new file mode 100644 index 000000000..26762665b --- /dev/null +++ b/store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/Store6RoomSchema.kt @@ -0,0 +1,37 @@ +package org.mobilenativefoundation.store6.room + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.execSQL +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi + +/** + * Migration SQL for the adapter-owned sidecar. + * + * When adding these tables to an existing Room database, bump its version by 1 and call + * [createTables] from the migration. + */ +@ExperimentalStoreApi +public object Store6RoomSchema { + public const val CREATE_BOOKKEEPING_TABLE: String = + "CREATE TABLE IF NOT EXISTS `store6_bookkeeping` (" + + "`namespace` TEXT NOT NULL, " + + "`canonical_id` TEXT NOT NULL, " + + "`written_at_epoch_millis` INTEGER, " + + "`etag` TEXT, " + + "`last_success_sequence` INTEGER, " + + "`last_failure_at_epoch_millis` INTEGER, " + + "`consecutive_failures` INTEGER NOT NULL, " + + "`stale_sequence` INTEGER, " + + "PRIMARY KEY(`namespace`, `canonical_id`))" + + public const val CREATE_WATERMARKS_TABLE: String = + "CREATE TABLE IF NOT EXISTS `store6_watermarks` (" + + "`scope` TEXT NOT NULL, " + + "`sequence` INTEGER NOT NULL, " + + "PRIMARY KEY(`scope`))" + + public fun createTables(connection: SQLiteConnection) { + connection.execSQL(CREATE_BOOKKEEPING_TABLE) + connection.execSQL(CREATE_WATERMARKS_TABLE) + } +} diff --git a/store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/Store6WatermarkEntity.kt b/store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/Store6WatermarkEntity.kt new file mode 100644 index 000000000..12b5145f1 --- /dev/null +++ b/store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/Store6WatermarkEntity.kt @@ -0,0 +1,21 @@ +package org.mobilenativefoundation.store6.room + +import androidx.room3.ColumnInfo +import androidx.room3.Entity +import androidx.room3.PrimaryKey +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi + +/** + * Adapter-owned watermark row. + * + * See [Store6BookkeepingEntity] for the required database inclusion and migration rules. + */ +@ExperimentalStoreApi +@Entity(tableName = "store6_watermarks") +public class Store6WatermarkEntity( + @PrimaryKey + @ColumnInfo(name = "scope") + public val scope: String, + @ColumnInfo(name = "sequence") + public val sequence: Long, +) diff --git a/store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomBookkeeperContractTest.kt b/store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomBookkeeperContractTest.kt new file mode 100644 index 000000000..9053f578f --- /dev/null +++ b/store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomBookkeeperContractTest.kt @@ -0,0 +1,31 @@ +@file:OptIn(ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.room + +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.testing.BookkeeperContractKit +import kotlin.test.AfterTest + +class RoomBookkeeperContractTest : BookkeeperContractKit() { + private val databases = mutableListOf() + + override fun createBookkeeper(): Bookkeeper { + val database = createTestDatabase().also { databases += it } + return RoomBookkeeper(database, database.store6BookkeeperDao()) + } + + @AfterTest + fun closeDatabases() { + var firstFailure: Throwable? = null + databases.forEach { database -> + try { + database.close() + } catch (failure: Throwable) { + if (firstFailure == null) firstFailure = failure + } + } + databases.clear() + firstFailure?.let { throw it } + } +} diff --git a/store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomBookkeeperTest.kt b/store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomBookkeeperTest.kt new file mode 100644 index 000000000..7558683de --- /dev/null +++ b/store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomBookkeeperTest.kt @@ -0,0 +1,249 @@ +@file:OptIn(ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.room + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.job +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.testing.TestStoreMeta +import kotlin.coroutines.cancellation.CancellationException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +class RoomBookkeeperTest { + @Test + fun watermarksAndSequence_surviveReopen(): TestResult = runTest { + val path = newTempDatabasePath() + val key = TestKey(namespace = "durable-watermark", id = "key") + val firstDatabase = openTestDatabase(path) + try { + val first = + RoomBookkeeper(firstDatabase, firstDatabase.store6BookkeeperDao()) + first.advanceStaleWatermark(key.namespace) + } finally { + firstDatabase.close() + } + + val reopenedDatabase = openTestDatabase(path) + try { + val reopened = + RoomBookkeeper(reopenedDatabase, reopenedDatabase.store6BookkeeperDao()) + + val watermarkOnly = assertNotNull(reopened.status(key)) + assertNull(watermarkOnly.meta) + assertNull(watermarkOnly.lastSuccessSequence) + assertTrue(watermarkOnly.durablyStale) + + reopened.recordSuccess(key, TestStoreMeta(1L, "e1")) + + val afterSuccess = assertNotNull(reopened.status(key)) + assertEquals(1L, afterSuccess.meta?.writtenAtEpochMillis) + assertEquals("e1", afterSuccess.meta?.etag) + assertFalse(afterSuccess.durablyStale) + } finally { + reopenedDatabase.close() + } + } + + @Test + fun sequence_monotoneAcrossReopen(): TestResult = runTest { + val path = newTempDatabasePath() + val key = TestKey(namespace = "durable-sequence", id = "key") + val firstDatabase = openTestDatabase(path) + try { + val first = + RoomBookkeeper(firstDatabase, firstDatabase.store6BookkeeperDao()) + first.recordSuccess(key, TestStoreMeta(1L, "e1")) + assertEquals(1L, assertNotNull(first.status(key)).lastSuccessSequence) + } finally { + firstDatabase.close() + } + + val reopenedDatabase = openTestDatabase(path) + try { + val reopened = + RoomBookkeeper(reopenedDatabase, reopenedDatabase.store6BookkeeperDao()) + reopened.markStale(key) + + assertTrue(assertNotNull(reopened.status(key)).durablyStale) + } finally { + reopenedDatabase.close() + } + } + + @Test + fun closedDatabase_recordSuccessAbsorbs_markStaleThrows(): TestResult = runTest { + val database = openTestDatabase(newTempDatabasePath()) + val bookkeeper = RoomBookkeeper(database, database.store6BookkeeperDao()) + val key = TestKey(namespace = "closed", id = "key") + try { + database.close() + + bookkeeper.recordSuccess(key, TestStoreMeta(1L, "e1")) + assertFailsWith { + bookkeeper.markStale(key) + } + assertNull(bookkeeper.status(key)) + } finally { + database.close() + } + } + + @Test + fun forget_preservesWatermarks(): TestResult = runTest { + val database = createTestDatabase() + try { + val bookkeeper = RoomBookkeeper(database, database.store6BookkeeperDao()) + val key = TestKey(namespace = "forget", id = "key") + bookkeeper.advanceStaleWatermark(key.namespace) + bookkeeper.recordSuccess(key, TestStoreMeta(1L, "e1")) + + bookkeeper.forget(key) + + val status = assertNotNull(bookkeeper.status(key)) + assertNull(status.meta) + assertNull(status.lastSuccessSequence) + assertNull(status.lastFailureAtEpochMillis) + assertEquals(0, status.consecutiveFailures) + assertTrue(status.durablyStale) + } finally { + database.close() + } + } + + @Test + fun callerCancellation_recordSuccessPropagates(): TestResult = runTest { + val database = createTestDatabase() + try { + val callerJob = CompletableDeferred() + val cancellation = CancellationException("recordSuccess caller cancelled") + val dao = CancelCallerOnRecordDao(callerJob, cancellation) + val bookkeeper = RoomBookkeeper(database, dao) + val returnedNormally = CompletableDeferred() + val propagatedCancellation = CompletableDeferred() + val child = + launch { + callerJob.complete(currentCoroutineContext().job) + try { + bookkeeper.recordSuccess( + TestKey(namespace = "cancel-success", id = "key"), + TestStoreMeta(1L, "e1"), + ) + returnedNormally.complete(Unit) + } catch (exception: CancellationException) { + propagatedCancellation.complete(Unit) + throw exception + } + } + + child.join() + + assertTrue(dao.recordEntered.isCompleted) + assertFalse(returnedNormally.isCompleted) + assertTrue(propagatedCancellation.isCompleted) + assertTrue(child.isCancelled) + } finally { + database.close() + } + } + + @Test + fun callerCancellation_statusPropagates(): TestResult = runTest { + val database = createTestDatabase() + try { + val callerJob = CompletableDeferred() + val cancellation = CancellationException("status caller cancelled") + val dao = CancelCallerOnRecordDao(callerJob, cancellation) + val bookkeeper = RoomBookkeeper(database, dao) + val returnedNormally = CompletableDeferred() + val propagatedCancellation = CompletableDeferred() + val child = + launch { + callerJob.complete(currentCoroutineContext().job) + try { + bookkeeper.status( + TestKey(namespace = "cancel-status", id = "key"), + ) + returnedNormally.complete(Unit) + } catch (exception: CancellationException) { + propagatedCancellation.complete(Unit) + throw exception + } + } + + child.join() + + assertTrue(dao.recordEntered.isCompleted) + assertFalse(returnedNormally.isCompleted) + assertTrue(propagatedCancellation.isCompleted) + assertTrue(child.isCancelled) + } finally { + database.close() + } + } + + private class TestKey( + namespace: String, + private val id: String, + ) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace(namespace) + + override fun canonicalId(): String = id + } + + private class CancelCallerOnRecordDao( + private val callerJob: CompletableDeferred, + private val cancellation: CancellationException, + ) : Store6BookkeeperDao { + val recordEntered = CompletableDeferred() + + override suspend fun record( + namespace: String, + canonicalId: String, + ): Store6BookkeepingEntity? { + recordEntered.complete(Unit) + callerJob.await().cancel(cancellation) + throw cancellation + } + + override suspend fun upsertRecord(record: Store6BookkeepingEntity) { + error("Unexpected upsertRecord") + } + + override suspend fun deleteRecord( + namespace: String, + canonicalId: String, + ) { + error("Unexpected deleteRecord") + } + + override suspend fun deleteNamespaceRecords(namespace: String) { + error("Unexpected deleteNamespaceRecords") + } + + override suspend fun deleteAllRecords() { + error("Unexpected deleteAllRecords") + } + + override suspend fun watermark(scope: String): Long? = null + + override suspend fun upsertWatermark(watermark: Store6WatermarkEntity) = Unit + } +} + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = 25.seconds, testBody = testBody) diff --git a/store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomSourceOfTruthContractTest.kt b/store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomSourceOfTruthContractTest.kt new file mode 100644 index 000000000..473ac3988 --- /dev/null +++ b/store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomSourceOfTruthContractTest.kt @@ -0,0 +1,57 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.room + +import kotlinx.coroutines.flow.map +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import org.mobilenativefoundation.store6.testing.SourceOfTruthContractKit +import kotlin.test.AfterTest + +internal class RoomSourceOfTruthContractTest : + SourceOfTruthContractKit() { + private val databases = mutableListOf() + + override fun createSourceOfTruth(): SourceOfTruth { + val database = createTestDatabase().also { databases += it } + val dao = database.kitRowDao() + return RoomSourceOfTruth( + database = database, + rowReader = { key -> + dao.row(key.namespace.value, key.canonicalId()).map { it?.payload } + }, + rowWriter = { key, value -> + dao.upsert(KitRowEntity(key.namespace.value, key.canonicalId(), value)) + }, + rowDeleter = { key -> + dao.delete(key.namespace.value, key.canonicalId()) + }, + namespaceDeleter = { namespace -> + dao.deleteNamespace(namespace.value) + }, + allDeleter = { + dao.deleteAll() + }, + ) + } + + override val keyA: RoomKitKey = RoomKitKey(StoreNamespace("users"), "a") + override val keyB: RoomKitKey = RoomKitKey(StoreNamespace("users"), "b") + override val keyOtherNamespace: RoomKitKey = RoomKitKey(StoreNamespace("teams"), "a") + + override fun value(index: Int): String = "value-$index" + + @AfterTest + fun closeDatabases() { + var firstFailure: Throwable? = null + databases.forEach { database -> + try { + database.close() + } catch (failure: Throwable) { + if (firstFailure == null) firstFailure = failure + } + } + databases.clear() + firstFailure?.let { throw it } + } +} diff --git a/store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomSourceOfTruthReaderSemanticsTest.kt b/store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomSourceOfTruthReaderSemanticsTest.kt new file mode 100644 index 000000000..8be132c7c --- /dev/null +++ b/store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomSourceOfTruthReaderSemanticsTest.kt @@ -0,0 +1,657 @@ +@file:OptIn( + kotlinx.coroutines.ExperimentalCoroutinesApi::class, + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, +) + +package org.mobilenativefoundation.store6.room + +import app.cash.turbine.test +import app.cash.turbine.turbineScope +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest as coroutineRunTest +import kotlinx.coroutines.yield +import org.mobilenativefoundation.store6.core.StoreNamespace +import kotlin.coroutines.cancellation.CancellationException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +internal class RoomSourceOfTruthReaderSemanticsTest { + private val keyA = RoomKitKey(StoreNamespace("users"), "a") + private val keyB = RoomKitKey(StoreNamespace("users"), "b") + + @Test + fun sameTableOtherRowWrite_doesNotReemit(): TestResult = runTest { + val database = createTestDatabase() + try { + val dao = database.kitRowDao() + val equalityObserved = CompletableDeferred() + val sourceOfTruth = equalityProbeSourceOfTruth(database, equalityObserved) + dao.upsert(row(keyA, "value-a")) + + sourceOfTruth.reader(keyA).test { + assertEquals("value-a", awaitItem()?.payload) + + dao.upsert(row(keyB, "value-b")) + equalityObserved.await() + expectNoEvents() + + cancelAndIgnoreRemainingEvents() + } + } finally { + database.close() + } + } + + @Test + fun externalDaoWrite_isObservedLive(): TestResult = runTest { + val database = createTestDatabase() + try { + val dao = database.kitRowDao() + val sourceOfTruth = sourceOfTruth(database) + dao.upsert(row(keyA, "value-1")) + + sourceOfTruth.reader(keyA).test { + assertEquals("value-1", awaitItem()) + + dao.upsert(row(keyA, "value-2")) + assertEquals("value-2", awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + } finally { + database.close() + } + } + + @Test + fun equalValueWriteAfterExternalEqualChange_stillEmits(): TestResult = runTest { + val database = createTestDatabase() + try { + val dao = database.kitRowDao() + val sourceOfTruth = sourceOfTruth(database) + + sourceOfTruth.reader(keyA).test { + assertNull(awaitItem()) + + dao.upsert(row(keyA, "value")) + assertEquals("value", awaitItem()) + + sourceOfTruth.write(keyA, "value") + assertEquals("value", awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + } finally { + database.close() + } + } + + @Test + fun registrations_removedWhenLastReaderCancels(): TestResult = runTest { + val database = createTestDatabase() + try { + val sourceOfTruth = sourceOfTruth(database) + + turbineScope { + val first = sourceOfTruth.reader(keyA).testIn(backgroundScope) + val second = sourceOfTruth.reader(keyA).testIn(backgroundScope) + try { + assertNull(first.awaitItem()) + assertNull(second.awaitItem()) + } finally { + first.cancelAndIgnoreRemainingEvents() + second.cancelAndIgnoreRemainingEvents() + } + } + + sourceOfTruth.write(keyA, "value") + sourceOfTruth.reader(keyA).test { + assertEquals("value", awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } finally { + database.close() + } + } + + @Test + fun writeFailure_rollsBackAndEchoesNothing(): TestResult = runTest { + val database = createTestDatabase() + try { + val dao = database.kitRowDao() + val sourceOfTruth = + sourceOfTruth(database) { key, value -> + dao.upsert(row(key, value)) + throw IllegalStateException("write failed") + } + + sourceOfTruth.reader(keyA).test { + assertNull(awaitItem()) + + assertFailsWith { + sourceOfTruth.write(keyA, "rolled-back") + } + runCurrent() + expectNoEvents() + assertNull(dao.row(keyA.namespace.value, keyA.canonicalId()).first()) + + dao.upsert(row(keyA, "committed")) + assertEquals("committed", awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + } finally { + database.close() + } + } + + @Test + fun explicitCancellationFromMutation_rollsBackAndThrows(): TestResult = runTest { + val database = createTestDatabase() + try { + val dao = database.kitRowDao() + val cancellation = CancellationException("mutation cancelled itself") + val sourceOfTruth = + sourceOfTruth(database) { key, value -> + dao.upsert(row(key, value)) + throw cancellation + } + + assertFailsWith { + sourceOfTruth.write(keyA, "rolled-back") + } + assertNull(dao.row(keyA.namespace.value, keyA.canonicalId()).first()) + } finally { + database.close() + } + } + + @Test + fun twoAdaptersOnSameDatabase_nestedAndCompetingWrites_doNotInvertLocks(): TestResult = + runTest { + val database = createTestDatabase() + val allowNestedWrite = CompletableDeferred() + var outerTransaction: Deferred? = null + var competingWrite: Deferred? = null + try { + val dao = database.kitRowDao() + val adapterA = sourceOfTruth(database) + val adapterB = sourceOfTruth(database) + val outerHasWriter = CompletableDeferred() + + outerTransaction = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + adapterA.withTransaction { + outerHasWriter.complete(Unit) + allowNestedWrite.await() + adapterB.write(keyA, "nested") + } + } + outerHasWriter.await() + + competingWrite = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + adapterB.write(keyB, "competing") + } + allowNestedWrite.complete(Unit) + + outerTransaction.await() + competingWrite.await() + + assertEquals( + "nested", + dao.row(keyA.namespace.value, keyA.canonicalId()).first()?.payload, + ) + assertEquals( + "competing", + dao.row(keyB.namespace.value, keyB.canonicalId()).first()?.payload, + ) + } finally { + allowNestedWrite.complete(Unit) + outerTransaction?.cancel() + competingWrite?.cancel() + outerTransaction?.join() + competingWrite?.join() + database.close() + } + } + + @Test + fun readerJoiningFailedAllocation_equalRewriteEmitsExactlyOnce(): TestResult = runTest { + val database = createTestDatabase() + val releaseFailure = CompletableDeferred() + var failedWrite: Deferred>? = null + try { + val failedWriterEntered = CompletableDeferred() + val rows = MutableStateFlow("same") + val sourceOfTruth = + flowProbeSourceOfTruth(database, rows) { _, value -> + if (value == "fail") { + failedWriterEntered.complete(Unit) + releaseFailure.await() + error("allocation failed") + } + } + + sourceOfTruth.reader(keyA).test { + assertEquals("same", awaitItem()) + + failedWrite = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { sourceOfTruth.write(keyA, "fail") } + } + failedWriterEntered.await() + + sourceOfTruth.reader(keyA).test { + assertEquals("same", awaitItem()) + + releaseFailure.complete(Unit) + assertTrue(checkNotNull(failedWrite).await().isFailure) + runCurrent() + expectNoEvents() + + sourceOfTruth.write(keyA, "same") + assertEquals("same", awaitItem()) + expectNoEvents() + + cancelAndIgnoreRemainingEvents() + } + cancelAndIgnoreRemainingEvents() + } + } finally { + releaseFailure.complete(Unit) + failedWrite?.cancel() + failedWrite?.join() + database.close() + } + } + + @Test + fun readerJoiningSuccessfulAllocation_equalRewriteEmitsExactlyOnce(): TestResult = runTest { + val database = createTestDatabase() + val releaseWrite = CompletableDeferred() + var equalRewrite: Deferred>? = null + try { + val writerEntered = CompletableDeferred() + val rows = MutableStateFlow("same") + val sourceOfTruth = + flowProbeSourceOfTruth(database, rows) { _, _ -> + writerEntered.complete(Unit) + releaseWrite.await() + } + + sourceOfTruth.reader(keyA).test { + assertEquals("same", awaitItem()) + + equalRewrite = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { sourceOfTruth.write(keyA, "same") } + } + writerEntered.await() + + sourceOfTruth.reader(keyA).test { + assertEquals("same", awaitItem()) + + releaseWrite.complete(Unit) + assertTrue(checkNotNull(equalRewrite).await().isSuccess) + assertEquals("same", awaitItem()) + expectNoEvents() + + cancelAndIgnoreRemainingEvents() + } + cancelAndIgnoreRemainingEvents() + } + } finally { + releaseWrite.complete(Unit) + equalRewrite?.cancel() + equalRewrite?.join() + database.close() + } + } + + @Test + fun cancellationWhileAdmittedAfterDaoWrite_commitsAndReturnsNormally(): TestResult = runTest { + val database = createTestDatabase() + val releaseWriter = CompletableDeferred() + var write: Deferred? = null + val boundary = CompletableDeferred>() + try { + val dao = database.kitRowDao() + val daoWriteCompleted = CompletableDeferred() + val sourceOfTruth = + sourceOfTruth(database) { key, value -> + dao.upsert(row(key, value)) + daoWriteCompleted.complete(Unit) + releaseWriter.await() + } + + write = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + boundary.complete( + runCatching { sourceOfTruth.write(keyA, "committed") }, + ) + } + daoWriteCompleted.await() + write.cancel() + releaseWriter.complete(Unit) + + write.join() + assertTrue( + boundary.await().isSuccess, + "cancellation after admission must not escape a committed write", + ) + assertEquals( + "committed", + dao.row(keyA.namespace.value, keyA.canonicalId()).first()?.payload, + ) + } finally { + releaseWriter.complete(Unit) + write?.cancel() + write?.join() + database.close() + } + } + + @Test + fun cancelledBackpressuredWrite_publishesEchoAndDoesNotWedgeGate(): TestResult = runTest { + val databasePath = newTempDatabasePath() + val database = openTestDatabase(databasePath) + val externalDatabase = openTestDatabase(databasePath) + val releaseCollector = CompletableDeferred() + var collector: Job? = null + var externalCandidateObservation: Deferred? = null + var releaseWrite: Deferred? = null + var queuedWrite: Deferred? = null + var candidateWrite: Deferred? = null + val candidateBoundary = CompletableDeferred>() + try { + val dao = database.kitRowDao() + val externalDao = externalDatabase.kitRowDao() + assertNull(dao.row(keyA.namespace.value, keyA.canonicalId()).first()) + assertNull(externalDao.row(keyA.namespace.value, keyA.canonicalId()).first()) + val fillMergeBuffer = CompletableDeferred() + val mergeBufferFilled = CompletableDeferred() + val queryExternalValue = CompletableDeferred() + val sourceOfTruth = + backpressureProbeSourceOfTruth( + database = database, + fillMergeBuffer = fillMergeBuffer, + mergeBufferFilled = mergeBufferFilled, + queryExternalValue = queryExternalValue, + ) + val initialObserved = CompletableDeferred() + val blockedEchoObserved = CompletableDeferred() + val candidateEchoObserved = CompletableDeferred() + val externalChangeObserved = CompletableDeferred() + val blockingValue = EchoProbeValue("block") + val candidateValue = EchoProbeValue("candidate") + + collector = + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + sourceOfTruth.reader(keyA).collect { value -> + when { + value == null -> initialObserved.complete(Unit) + value === blockingValue -> { + blockedEchoObserved.complete(Unit) + releaseCollector.await() + } + value === candidateValue -> candidateEchoObserved.complete(Unit) + value.payload == "external" -> externalChangeObserved.complete(Unit) + } + } + } + initialObserved.await() + + sourceOfTruth.write(keyA, blockingValue) + blockedEchoObserved.await() + fillMergeBuffer.complete(Unit) + mergeBufferFilled.await() + + sourceOfTruth.write(keyA, EchoProbeValue("in-flight")) + runCurrent() + repeat(64) { index -> + sourceOfTruth.write(keyA, EchoProbeValue("buffer-$index")) + } + + externalCandidateObservation = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + while ( + externalDao + .row(keyA.namespace.value, keyA.canonicalId()) + .first() + ?.payload != "candidate" + ) { + yield() + } + } + candidateWrite = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + candidateBoundary.complete( + runCatching { sourceOfTruth.write(keyA, candidateValue) }, + ) + } + externalCandidateObservation.await() + runCurrent() + assertFalse( + candidateWrite.isCompleted, + "the candidate echo must be backpressured by the saturated buffer", + ) + + candidateWrite.cancel() + runCurrent() + queuedWrite = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + sourceOfTruth.write(keyB, EchoProbeValue("queued")) + } + releaseWrite = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + dao.upsert(row(keyB, "release")) + releaseCollector.complete(Unit) + } + releaseWrite.await() + candidateEchoObserved.await() + candidateWrite.join() + assertTrue( + candidateBoundary.await().isSuccess, + "cancellation after the durable commit must not throw from write", + ) + assertEquals( + "candidate", + dao.row(keyA.namespace.value, keyA.canonicalId()).first()?.payload, + "the candidate transaction must remain committed after writer cancellation", + ) + + queuedWrite.await() + assertEquals( + "queued", + dao.row(keyB.namespace.value, keyB.canonicalId()).first()?.payload, + ) + externalDao.upsert(row(keyA, "external")) + queryExternalValue.complete(Unit) + externalChangeObserved.await() + } finally { + releaseCollector.complete(Unit) + candidateWrite?.cancel() + externalCandidateObservation?.cancel() + queuedWrite?.cancel() + releaseWrite?.cancel() + collector?.cancel() + externalDatabase.close() + database.close() + candidateWrite?.join() + externalCandidateObservation?.join() + queuedWrite?.join() + releaseWrite?.join() + collector?.join() + } + } + + private fun sourceOfTruth( + database: Store6RoomTestDatabase, + rowWriter: (suspend (RoomKitKey, String) -> Unit)? = null, + ): RoomSourceOfTruth { + val dao = database.kitRowDao() + val writer: suspend (RoomKitKey, String) -> Unit = + rowWriter ?: { key, value -> + dao.upsert(row(key, value)) + } + return RoomSourceOfTruth( + database = database, + rowReader = { key -> + dao.row(key.namespace.value, key.canonicalId()).map { it?.payload } + }, + rowWriter = writer, + rowDeleter = { key -> + dao.delete(key.namespace.value, key.canonicalId()) + }, + namespaceDeleter = { namespace -> + dao.deleteNamespace(namespace.value) + }, + allDeleter = { + dao.deleteAll() + }, + ) + } + + private fun flowProbeSourceOfTruth( + database: Store6RoomTestDatabase, + rows: MutableStateFlow, + rowWriter: suspend (RoomKitKey, String) -> Unit, + ): RoomSourceOfTruth = + RoomSourceOfTruth( + database = database, + rowReader = { rows }, + rowWriter = rowWriter, + rowDeleter = {}, + namespaceDeleter = {}, + allDeleter = {}, + ) + + private fun backpressureProbeSourceOfTruth( + database: Store6RoomTestDatabase, + fillMergeBuffer: CompletableDeferred, + mergeBufferFilled: CompletableDeferred, + queryExternalValue: CompletableDeferred, + ): RoomSourceOfTruth { + val dao = database.kitRowDao() + return RoomSourceOfTruth( + database = database, + rowReader = { key -> + kotlinx.coroutines.flow.flow { + suspend fun currentValue(): EchoProbeValue? = + dao + .row(key.namespace.value, key.canonicalId()) + .first() + ?.let { EchoProbeValue(it.payload) } + + emit(currentValue()) + fillMergeBuffer.await() + repeat(64) { index -> + emit(EchoProbeValue("database-buffer-$index")) + } + mergeBufferFilled.complete(Unit) + emit(EchoProbeValue("database-buffer-in-flight")) + queryExternalValue.await() + emit(currentValue()) + } + }, + rowWriter = { key, value -> + dao.upsert(row(key, value.payload)) + }, + rowDeleter = { key -> + dao.delete(key.namespace.value, key.canonicalId()) + }, + namespaceDeleter = { namespace -> + dao.deleteNamespace(namespace.value) + }, + allDeleter = { + dao.deleteAll() + }, + ) + } + + private fun equalityProbeSourceOfTruth( + database: Store6RoomTestDatabase, + equalityObserved: CompletableDeferred, + ): RoomSourceOfTruth { + val dao = database.kitRowDao() + return RoomSourceOfTruth( + database = database, + rowReader = { key -> + dao.row(key.namespace.value, key.canonicalId()).map { entity -> + entity?.let { EqualityProbeValue(it.payload, equalityObserved) } + } + }, + rowWriter = { key, value -> + dao.upsert(row(key, value.payload)) + }, + rowDeleter = { key -> + dao.delete(key.namespace.value, key.canonicalId()) + }, + namespaceDeleter = { namespace -> + dao.deleteNamespace(namespace.value) + }, + allDeleter = { + dao.deleteAll() + }, + ) + } + + private fun row( + key: RoomKitKey, + value: String, + ): KitRowEntity = + KitRowEntity( + namespace = key.namespace.value, + id = key.canonicalId(), + payload = value, + ) + + private class EqualityProbeValue( + val payload: String, + private val equalityObserved: CompletableDeferred, + ) { + override fun equals(other: Any?): Boolean { + val equal = other is EqualityProbeValue && payload == other.payload + if (equal) { + equalityObserved.complete(Unit) + } + return equal + } + + override fun hashCode(): Int = payload.hashCode() + } + + private data class EchoProbeValue( + val payload: String, + ) +} + +// Turbine's 3s default would nest inside the 25s shadow. Raising the Turbine deadline above +// the shadow makes runTest the only effective timeout. +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds // strictly > TEST_TIMEOUT: the shadow must fire first + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } diff --git a/store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomStoreSubstitutionConformanceTest.kt b/store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomStoreSubstitutionConformanceTest.kt new file mode 100644 index 000000000..019fea28c --- /dev/null +++ b/store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomStoreSubstitutionConformanceTest.kt @@ -0,0 +1,628 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, +) + +package org.mobilenativefoundation.store6.room + +import app.cash.turbine.test +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreException +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.seam.Fetcher +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import org.mobilenativefoundation.store6.core.seam.FetchPlan +import org.mobilenativefoundation.store6.core.seam.FreshnessContext +import org.mobilenativefoundation.store6.core.seam.FreshnessValidator +import org.mobilenativefoundation.store6.testing.FakeFetcher +import org.mobilenativefoundation.store6.testing.TestWallClock +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.test.fail +import kotlin.time.Duration.Companion.seconds + +internal class RoomStoreSubstitutionConformanceTest { + private val users = StoreNamespace("users") + private val teams = StoreNamespace("teams") + private val keyA = RoomKitKey(users, "a") + private val keyB = RoomKitKey(users, "b") + private val otherNamespaceKey = RoomKitKey(teams, "a") + + /** A cold public Store stream persists its fetched value through Room. */ + @Test + fun coldStream_loadingThenFetcherData_rowPersisted(): TestResult = runTest { + val database = createTestDatabase() + val rig = Rig(database) + val store = rig.store() + rig.fetcher.enqueue(keyA, FetcherResult.Success("v1")) + try { + store.stream(keyA).test { + assertIs(awaitItem()) + val data = assertIs>(awaitItem()) + assertEquals("v1", data.value) + assertEquals(Origin.FETCHER, data.origin) + assertFalse(data.isStale) + cancelAndIgnoreRemainingEvents() + } + + assertEquals( + "v1", + database + .kitRowDao() + .row(keyA.namespace.value, keyA.canonicalId()) + .first() + ?.payload, + ) + } finally { + store.closeAndSettleForTest() + database.close() + } + } + + /** A fresh durable row and sidecar let a new Store skip fetching. */ + @Test + fun secondStoreOverSameDatabase_servesSotWithoutFetch(): TestResult = runTest { + val database = createTestDatabase() + val sharedClock = TestWallClock() + val rigA = Rig(database, sharedClock) + val storeA = rigA.store() + var storeB: Store? = null + try { + rigA.fetcher.enqueue(keyA, FetcherResult.Success("v1", etag = "e1")) + assertEquals("v1", storeA.get(keyA)) + storeA.closeAndSettleForTest() + + val rigB = Rig(database, sharedClock) + storeB = rigB.store() + assertEquals("v1", storeB.get(keyA)) + assertTrue(rigB.fetcher.invocations.isEmpty()) + } finally { + storeB?.closeAndSettleForTest() + storeA.closeAndSettleForTest() + database.close() + } + } + + /** Disk hydration refetches unconditionally, then same-engine ETag reuse is conditional. */ + @Test + fun durableEtag_roundTrips_asConditionalFetch_revalidated(): TestResult = runTest { + val database = createTestDatabase() + val sharedClock = TestWallClock() + val rigA = Rig(database, sharedClock) + val storeA = rigA.store() + val conditionalGate = FetchGate(blockFromInvocation = 2) + var storeB: Store? = null + try { + rigA.fetcher.enqueue(keyA, FetcherResult.Success("v1", etag = "e1")) + assertEquals("v1", storeA.get(keyA)) + storeA.closeAndSettleForTest() + + val rigB = Rig(database, sharedClock) + val durableStatus = assertNotNull(rigB.bookkeeper().status(keyA)) + assertEquals("e1", durableStatus.meta?.etag) + assertFalse(durableStatus.durablyStale) + rigB.fetcher.enqueue(keyA, FetcherResult.Success("v1", etag = "e1")) + rigB.fetcher.onUnscripted = { _, _ -> + error("unexpected fetch after the unconditional and conditional requests") + } + storeB = rigB.store(conditionalGate) + assertEquals("v1", storeB.get(keyA, Freshness.LocalOnly)) + assertTrue(rigB.fetcher.invocations.isEmpty()) + storeB.invalidate(keyA) + + assertEquals( + "v1", + storeB.get(keyA, Freshness.MaxAge(notOlderThan = 1.seconds)), + ) + assertEquals(1, rigB.fetcher.invocations.size) + assertNull(rigB.fetcher.invocations.single().etag) + + rigB.fetcher.enqueue( + keyA, + FetcherResult.NotModified(), + FetcherResult.NotModified(), + ) + storeB.invalidate(keyA) + + storeB.stream(keyA).test { + val stale = assertIs>(awaitItem()) + assertEquals("v1", stale.value) + assertTrue(stale.isStale) + assertTrue(stale.refreshing) + + conditionalGate.entered.await() + assertEquals(1, rigB.fetcher.invocations.size) + conditionalGate.release.complete(Unit) + while (true) { + when (val item = awaitItem()) { + is StoreResult.Data -> { + assertEquals("v1", item.value) + if (item.origin == Origin.FETCHER && !item.isStale) { + fail("fresh FETCHER Data must be represented by Revalidated") + } + } + is StoreResult.Revalidated -> break + else -> fail("unexpected lifecycle item ${item::class.simpleName}") + } + } + cancelAndIgnoreRemainingEvents() + } + + val callsAfterRevalidation = rigB.fetcher.invocations.size + assertTrue(callsAfterRevalidation in 2..3) + assertTrue(rigB.fetcher.invocations.drop(1).all { it.etag == "e1" }) + assertEquals("v1", storeB.get(keyA)) + assertEquals(callsAfterRevalidation, rigB.fetcher.invocations.size) + } finally { + conditionalGate.release.complete(Unit) + storeB?.closeAndSettleForTest() + storeA.closeAndSettleForTest() + database.close() + } + } + + /** A namespace watermark survives Store replacement and forces a refetch. */ + @Test + fun invalidateNamespace_survivesRestart_forcesRefetch(): TestResult = runTest { + val database = createTestDatabase() + val sharedClock = TestWallClock() + val rigA = Rig(database, sharedClock) + val storeA = rigA.store() + val refetchGate = FetchGate() + var storeB: Store? = null + try { + rigA.fetcher.enqueue(keyA, FetcherResult.Success("v1")) + assertEquals("v1", storeA.get(keyA)) + storeA.invalidateNamespace(users) + storeA.closeAndSettleForTest() + + val rigB = Rig(database, sharedClock) + rigB.fetcher.enqueue(keyA, FetcherResult.Success("v2")) + storeB = rigB.store(refetchGate) + storeB.stream(keyA).test { + val initial = awaitItem() + val stale = + if (initial is StoreResult.Loading) { + assertIs>(awaitItem()) + } else { + assertIs>(initial) + } + assertEquals("v1", stale.value) + assertEquals(Origin.SOT, stale.origin) + assertTrue(stale.isStale) + assertTrue(stale.refreshing) + + refetchGate.entered.await() + refetchGate.release.complete(Unit) + while (true) { + val item = awaitItem() + if (item is StoreResult.Data && item.value == "v2") { + assertEquals(Origin.FETCHER, item.origin) + assertFalse(item.isStale) + break + } + val replay = assertIs>(item) + assertEquals("v1", replay.value) + assertTrue(replay.isStale) + } + cancelAndIgnoreRemainingEvents() + } + assertEquals(1, rigB.fetcher.invocations.size) + } finally { + refetchGate.release.complete(Unit) + storeB?.closeAndSettleForTest() + storeA.closeAndSettleForTest() + database.close() + } + } + + /** Clear removes both the user row and its durable freshness record. */ + @Test + fun clear_deletesRowAndForgetsBookkeeping(): TestResult = runTest { + val database = createTestDatabase() + val rig = Rig(database) + val store = rig.store() + try { + rig.fetcher.enqueue(keyA, FetcherResult.Success("v1", etag = "e1")) + assertEquals("v1", store.get(keyA)) + assertNotNull(rig.bookkeeper().status(keyA)) + + store.clear(keyA) + + assertNull( + database + .kitRowDao() + .row(keyA.namespace.value, keyA.canonicalId()) + .first(), + ) + assertNull(rig.bookkeeper().status(keyA)) + + rig.fetcher.enqueue(keyA, FetcherResult.Success("v2")) + assertEquals("v2", store.get(keyA)) + assertEquals(2, rig.fetcher.invocations.size) + } finally { + store.closeAndSettleForTest() + database.close() + } + } + + /** clearNamespace runs the user delete and sweeps matching durable metadata only. */ + @Test + fun clearNamespace_runsUserStatementAndSweeps(): TestResult = runTest { + val database = createTestDatabase() + val rig = Rig(database) + val store = rig.store() + try { + rig.fetcher.enqueue(keyA, FetcherResult.Success("value-a")) + rig.fetcher.enqueue(keyB, FetcherResult.Success("value-b")) + rig.fetcher.enqueue(otherNamespaceKey, FetcherResult.Success("value-other")) + assertEquals("value-a", store.get(keyA)) + assertEquals("value-b", store.get(keyB)) + assertEquals("value-other", store.get(otherNamespaceKey)) + + store.clearNamespace(users) + + val dao = database.kitRowDao() + assertNull(dao.row(users.value, keyA.canonicalId()).first()) + assertNull(dao.row(users.value, keyB.canonicalId()).first()) + assertEquals( + "value-other", + dao.row(teams.value, otherNamespaceKey.canonicalId()).first()?.payload, + ) + val bookkeeper = rig.bookkeeper() + assertNull(bookkeeper.status(keyA)) + assertNull(bookkeeper.status(keyB)) + assertNotNull(bookkeeper.status(otherNamespaceKey)) + } finally { + store.closeAndSettleForTest() + database.close() + } + } + + /** StaleIfError serves residence, reports failure, and remains causal-live. */ + @Test + fun staleIfError_servesStaleAndErrorStaysLive(): TestResult = runTest { + val database = createTestDatabase() + val rig = Rig(database) + val recoveryGate = FetchGate(blockFromInvocation = 3) + val store = rig.store(recoveryGate) + val boom = IllegalStateException("boom") + try { + rig.fetcher.enqueue( + keyA, + FetcherResult.Success("v1"), + FetcherResult.Error(boom), + FetcherResult.Success("v2"), + ) + assertEquals("v1", store.get(keyA)) + store.invalidate(keyA) + + store.stream(keyA, Freshness.StaleIfError).test { + val stale = assertIs>(awaitItem()) + assertEquals("v1", stale.value) + assertTrue(stale.isStale) + + var terminal = awaitItem() + var queuedStaleReplays = 0 + while (terminal is StoreResult.Data<*>) { + queuedStaleReplays += 1 + assertTrue( + queuedStaleReplays <= 1, + "more than one queued stale replay preceded the error", + ) + assertEquals("v1", terminal.value) + assertTrue(terminal.isStale) + terminal = awaitItem() + } + val failure = assertIs(terminal) + val fetch = assertIs(failure.error) + assertTrue(fetch.cause === boom) + assertTrue(failure.servedStale) + + store.clear(keyA) + var afterClear = awaitItem() + var queuedPreClearReplays = 0 + while (afterClear !is StoreResult.Loading) { + queuedPreClearReplays += 1 + assertTrue( + queuedPreClearReplays <= 1, + "more than one queued pre-clear replay preceded Loading", + ) + assertEquals( + "v1", + assertIs>(afterClear).value, + ) + afterClear = awaitItem() + } + recoveryGate.entered.await() + recoveryGate.release.complete(Unit) + val recovered = assertIs>(awaitItem()) + assertEquals("v2", recovered.value) + assertEquals(Origin.FETCHER, recovered.origin) + assertFalse(recovered.isStale) + cancelAndIgnoreRemainingEvents() + } + assertEquals(3, rig.fetcher.invocations.size) + } finally { + recoveryGate.release.complete(Unit) + store.closeAndSettleForTest() + database.close() + } + } + + /** A remote deletion cannot satisfy MustBeFresh on a missing row. */ + @Test + fun mustBeFresh_missingRow_throwsStoreException(): TestResult = runTest { + val database = createTestDatabase() + val rig = Rig(database) + val store = rig.store() + try { + rig.fetcher.enqueue(keyA, FetcherResult.Deleted) + + val failure = + assertFailsWith { + store.get(keyA, Freshness.MustBeFresh) + } + + assertIs(failure.error) + assertEquals(1, rig.fetcher.invocations.size) + } finally { + store.closeAndSettleForTest() + database.close() + } + } + + /** LocalOnly hydrates a user-seeded Room row without fetching. */ + @Test + fun localOnly_servesSeededRowWithoutFetch(): TestResult = runTest { + val database = createTestDatabase() + val rig = Rig(database) + val store = rig.store() + try { + database.kitRowDao().upsert(row(keyA, "seeded")) + + assertEquals("seeded", store.get(keyA, Freshness.LocalOnly)) + assertTrue(rig.fetcher.invocations.isEmpty()) + } finally { + store.closeAndSettleForTest() + database.close() + } + } + + /** Concurrent public gets share one cold-key fetch. */ + @Test + fun concurrentGets_singleFlight(): TestResult = runTest { + val database = createTestDatabase() + val rig = Rig(database) + val fetchGate = FetchGate() + val planningWitness = ColdFetchPlanningWitness() + val store = rig.store(fetchGate, planningWitness) + var firstRequest: Deferred? = null + var secondRequest: Deferred? = null + try { + rig.fetcher.enqueue(keyA, FetcherResult.Success("v1")) + + firstRequest = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.get(keyA) + } + // A cold get plans once before reservation and again while reservation holds the + // key-state lock. Consuming both first-get tokens prevents witness misattribution. + planningWitness.awaitPlan() + planningWitness.awaitPlan() + fetchGate.entered.await() + secondRequest = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.get(keyA) + } + planningWitness.awaitPlan() + // The second token is emitted under that lock immediately before the Join transition. + planningWitness.awaitPlan() + val activeFirstRequest = checkNotNull(firstRequest) + val activeSecondRequest = checkNotNull(secondRequest) + assertFalse(activeFirstRequest.isCompleted) + assertFalse(activeSecondRequest.isCompleted) + fetchGate.release.complete(Unit) + val values = listOf(activeFirstRequest, activeSecondRequest).awaitAll() + + assertEquals(listOf("v1", "v1"), values) + assertEquals(1, rig.fetcher.invocations.size) + } finally { + fetchGate.release.complete(Unit) + firstRequest?.cancel() + secondRequest?.cancel() + store.closeAndSettleForTest() + firstRequest?.join() + secondRequest?.join() + database.close() + } + } + + /** MaxAge uses durable write time after Store replacement and withholds stale data. */ + @Test + fun maxAge_refetchesWhenDurableMetaTooOld(): TestResult = runTest { + val database = createTestDatabase() + val sharedClock = TestWallClock(startEpochMillis = 0L) + val rigA = Rig(database, sharedClock) + val storeA = rigA.store() + var storeB: Store? = null + try { + rigA.fetcher.enqueue(keyA, FetcherResult.Success("v1")) + assertEquals("v1", storeA.get(keyA)) + sharedClock.advanceBy(2.seconds) + storeA.closeAndSettleForTest() + + val rigB = Rig(database, sharedClock) + rigB.fetcher.enqueue(keyA, FetcherResult.Success("v2")) + storeB = rigB.store() + + assertEquals( + "v2", + storeB.get(keyA, Freshness.MaxAge(notOlderThan = 1.seconds)), + ) + assertEquals(1, rigB.fetcher.invocations.size) + } finally { + storeB?.closeAndSettleForTest() + storeA.closeAndSettleForTest() + database.close() + } + } + + private fun row( + key: RoomKitKey, + value: String, + ): KitRowEntity = + KitRowEntity( + namespace = key.namespace.value, + id = key.canonicalId(), + payload = value, + ) +} + +private class Rig( + val database: Store6RoomTestDatabase, + val wallClock: TestWallClock = TestWallClock(), +) { + val fetcher = FakeFetcher() + + fun sourceOfTruth(): RoomSourceOfTruth { + val dao = database.kitRowDao() + return RoomSourceOfTruth( + database = database, + rowReader = { key -> + dao.row(key.namespace.value, key.canonicalId()).map { it?.payload } + }, + rowWriter = { key, value -> + dao.upsert(KitRowEntity(key.namespace.value, key.canonicalId(), value)) + }, + rowDeleter = { key -> + dao.delete(key.namespace.value, key.canonicalId()) + }, + namespaceDeleter = { namespace -> + dao.deleteNamespace(namespace.value) + }, + allDeleter = { + dao.deleteAll() + }, + ) + } + + fun bookkeeper(): RoomBookkeeper = + RoomBookkeeper(database, database.store6BookkeeperDao()) + + fun store( + fetchGate: FetchGate? = null, + planningValidator: FreshnessValidator? = null, + ): Store { + val installedFetcher = + if (fetchGate == null) { + fetcher + } else { + object : Fetcher { + override suspend fun fetch( + key: RoomKitKey, + etag: String?, + ): FetcherResult { + fetchGate.awaitBeforeFetch() + return this@Rig.fetcher.fetch(key, etag) + } + } + } + return org.mobilenativefoundation.store6.core.store { + fetcher(installedFetcher) + persistence(this@Rig.sourceOfTruth()) + bookkeeper(this@Rig.bookkeeper()) + wallClock(this@Rig.wallClock) + if (planningValidator != null) { + freshnessValidator(planningValidator) + } + } + } +} + +private class ColdFetchPlanningWitness : FreshnessValidator { + private val plans = Channel(capacity = Channel.UNLIMITED) + + override fun plan(context: FreshnessContext): FetchPlan { + check(context.freshness == Freshness.CachedOrFetch) { + "single-flight witness requires CachedOrFetch" + } + check(!context.hasResidentValue) { + "single-flight witness requires a cold key" + } + check(context.meta == null) + check(!context.epochStale) + check(context.status == null) + check(plans.trySend(Unit).isSuccess) + return FetchPlan.Fetch(servesResidentWhileFetching = false) + } + + suspend fun awaitPlan() { + plans.receive() + } +} + +private class FetchGate( + private val blockFromInvocation: Int = 1, +) { + private val lock = Mutex() + private var invocationCount = 0 + val entered = CompletableDeferred() + val release = CompletableDeferred() + + init { + require(blockFromInvocation >= 1) + } + + suspend fun awaitBeforeFetch() { + val shouldBlock = + lock.withLock { + invocationCount += 1 + invocationCount >= blockFromInvocation + } + if (shouldBlock) { + entered.complete(Unit) + release.await() + } + } +} + +private suspend fun Store<*, *>.closeAndSettleForTest() { + close() +} + +// Turbine's 3s default would nest inside the 25s shadow. Raising the Turbine deadline above +// the shadow makes runTest the only effective timeout. +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds // strictly > TEST_TIMEOUT: the shadow must fire first + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } diff --git a/store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomTransactionalSourceOfTruthTest.kt b/store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomTransactionalSourceOfTruthTest.kt new file mode 100644 index 000000000..6c04f52e2 --- /dev/null +++ b/store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomTransactionalSourceOfTruthTest.kt @@ -0,0 +1,394 @@ +@file:OptIn( + kotlinx.coroutines.ExperimentalCoroutinesApi::class, + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, +) + +package org.mobilenativefoundation.store6.room + +import app.cash.turbine.test +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.testing.TestStoreMeta +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +internal class RoomTransactionalSourceOfTruthTest { + private val keyA = RoomKitKey(StoreNamespace("users"), "a") + + @Test + fun withTransaction_writeThenThrow_rollsBackRow(): TestResult = runTest { + val database = createTestDatabase() + try { + val sourceOfTruth = sourceOfTruth(database) + val dao = database.kitRowDao() + + assertFailsWith { + sourceOfTruth.withTransaction { + sourceOfTruth.write(keyA, "v1") + error("boom") + } + } + + assertNull(dao.row(keyA.namespace.value, keyA.canonicalId()).first()) + assertNull(sourceOfTruth.reader(keyA).first()) + } finally { + database.close() + } + } + + @Test + fun withTransaction_valueAndMeta_commitAtomically(): TestResult = runTest { + val database = createTestDatabase() + try { + val sourceOfTruth = sourceOfTruth(database) + val bookkeeper = RoomBookkeeper(database, database.store6BookkeeperDao()) + + sourceOfTruth.withTransaction { + sourceOfTruth.write(keyA, "v1") + bookkeeper.recordSuccess( + keyA, + TestStoreMeta(writtenAtEpochMillis = 1L, etag = "e1"), + ) + } + + assertEquals( + "v1", + database + .kitRowDao() + .row(keyA.namespace.value, keyA.canonicalId()) + .first() + ?.payload, + ) + assertEquals("e1", bookkeeper.status(keyA)?.meta?.etag) + + assertFailsWith { + sourceOfTruth.withTransaction { + sourceOfTruth.write(keyA, "v2") + bookkeeper.recordSuccess( + keyA, + TestStoreMeta(writtenAtEpochMillis = 2L, etag = "e2"), + ) + error("boom after value and metadata") + } + } + + assertEquals( + "v1", + database + .kitRowDao() + .row(keyA.namespace.value, keyA.canonicalId()) + .first() + ?.payload, + ) + assertEquals("e1", bookkeeper.status(keyA)?.meta?.etag) + } finally { + database.close() + } + } + + @Test + fun withTransaction_throwBetweenValueAndMeta_rollsBackBoth(): TestResult = runTest { + val database = createTestDatabase() + try { + val sourceOfTruth = sourceOfTruth(database) + val bookkeeper = RoomBookkeeper(database, database.store6BookkeeperDao()) + + assertFailsWith { + sourceOfTruth.withTransaction { + sourceOfTruth.write(keyA, "v1") + error("boom before metadata") + } + } + + assertNull( + database + .kitRowDao() + .row(keyA.namespace.value, keyA.canonicalId()) + .first(), + ) + assertNull(bookkeeper.status(keyA)) + } finally { + database.close() + } + } + + @Test + fun withTransaction_returnsBlockResult(): TestResult = runTest { + val database = createTestDatabase() + try { + val sourceOfTruth = sourceOfTruth(database) + + val result = sourceOfTruth.withTransaction { "block-result" } + + assertEquals("block-result", result) + } finally { + database.close() + } + } + + @Test + fun nestedWrite_outerRollback_neverPublishesEcho(): TestResult = runTest { + val database = createTestDatabase() + val releaseOuter = CompletableDeferred() + var transaction: Deferred>? = null + try { + val sourceOfTruth = sourceOfTruth(database) + val nestedWriteReturned = CompletableDeferred() + + sourceOfTruth.reader(keyA).test { + assertNull(awaitItem()) + + transaction = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { + sourceOfTruth.withTransaction { + sourceOfTruth.write(keyA, "rolled-back") + nestedWriteReturned.complete(Unit) + releaseOuter.await() + error("rollback outer transaction") + } + } + } + nestedWriteReturned.await() + runCurrent() + expectNoEvents() + + releaseOuter.complete(Unit) + assertTrue(checkNotNull(transaction).await().isFailure) + runCurrent() + expectNoEvents() + assertNull( + database + .kitRowDao() + .row(keyA.namespace.value, keyA.canonicalId()) + .first(), + ) + + cancelAndIgnoreRemainingEvents() + } + } finally { + releaseOuter.complete(Unit) + transaction?.cancel() + transaction?.join() + database.close() + } + } + + @Test + fun nestedWrite_outerCommit_publishesEchoOnlyAfterCommit(): TestResult = runTest { + val database = createTestDatabase() + val allowCommit = CompletableDeferred() + var transaction: Deferred? = null + try { + val sourceOfTruth = sourceOfTruth(database) + val nestedWriteReturned = CompletableDeferred() + + sourceOfTruth.reader(keyA).test { + assertNull(awaitItem()) + + transaction = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + sourceOfTruth.withTransaction { + sourceOfTruth.write(keyA, "transient") + sourceOfTruth.write(keyA, "committed") + nestedWriteReturned.complete(Unit) + allowCommit.await() + } + } + nestedWriteReturned.await() + runCurrent() + expectNoEvents() + + allowCommit.complete(Unit) + checkNotNull(transaction).await() + assertEquals("committed", awaitItem()) + expectNoEvents() + + cancelAndIgnoreRemainingEvents() + } + } finally { + allowCommit.complete(Unit) + transaction?.cancel() + transaction?.join() + database.close() + } + } + + @Test + fun caughtNestedTransactionRollback_preservesOuterValueAndEcho(): TestResult = runTest { + val database = createTestDatabase() + try { + val sourceOfTruth = sourceOfTruth(database) + + sourceOfTruth.reader(keyA).test { + assertNull(awaitItem()) + + sourceOfTruth.withTransaction { + sourceOfTruth.write(keyA, "outer") + assertFailsWith { + sourceOfTruth.withTransaction { + sourceOfTruth.write(keyA, "rolled-back-nested") + error("rollback nested savepoint") + } + } + } + + assertEquals("outer", awaitItem()) + expectNoEvents() + assertEquals( + "outer", + database + .kitRowDao() + .row(keyA.namespace.value, keyA.canonicalId()) + .first() + ?.payload, + ) + + cancelAndIgnoreRemainingEvents() + } + } finally { + database.close() + } + } + + @Test + fun crossAdapterNestedWrite_outerRollbackPublishesNoEcho(): TestResult = runTest { + val database = createTestDatabase() + try { + val dao = database.kitRowDao() + val adapterA = sourceOfTruth(database) + val adapterB = sourceOfTruth(database) + dao.upsert( + KitRowEntity( + namespace = keyA.namespace.value, + id = keyA.canonicalId(), + payload = "prior", + ), + ) + + adapterB.reader(keyA).test { + assertEquals("prior", awaitItem()) + + assertFailsWith { + adapterA.withTransaction { + adapterB.write(keyA, "rolled-back") + error("rollback adapter A transaction") + } + } + runCurrent() + expectNoEvents() + assertEquals( + "prior", + dao.row(keyA.namespace.value, keyA.canonicalId()).first()?.payload, + ) + + cancelAndIgnoreRemainingEvents() + } + } finally { + database.close() + } + } + + @Test + fun launchedChild_failsFastInsteadOfWaitingOnParentsDatabaseAdmission(): TestResult = runTest { + val database = createTestDatabase() + try { + val childWriterEntered = CompletableDeferred() + val sourceOfTruth = + sourceOfTruth(database) { key, value -> + childWriterEntered.complete(Unit) + database + .kitRowDao() + .upsert(KitRowEntity(key.namespace.value, key.canonicalId(), value)) + } + + sourceOfTruth.withTransaction { + coroutineScope { + val child = + async(start = CoroutineStart.UNDISPATCHED) { + runCatching { + sourceOfTruth.write(keyA, "child") + } + } + runCurrent() + assertTrue( + child.isCompleted, + "a child coroutine must fail instead of waiting on its parent's admission", + ) + val failure = assertIs(child.await().exceptionOrNull()) + assertEquals( + "Room transaction mutations must remain sequential in the owning coroutine", + failure.message, + ) + assertFalse( + childWriterEntered.isCompleted, + "a child coroutine must not enter the inherited Room transaction", + ) + } + } + + assertNull( + database + .kitRowDao() + .row(keyA.namespace.value, keyA.canonicalId()) + .first(), + ) + } finally { + database.close() + } + } +} + +private fun sourceOfTruth( + database: Store6RoomTestDatabase, + rowWriter: (suspend (RoomKitKey, String) -> Unit)? = null, +): RoomSourceOfTruth { + val dao = database.kitRowDao() + return RoomSourceOfTruth( + database = database, + rowReader = { key -> + dao.row(key.namespace.value, key.canonicalId()).map { it?.payload } + }, + rowWriter = + rowWriter ?: { key, value -> + dao.upsert(KitRowEntity(key.namespace.value, key.canonicalId(), value)) + }, + rowDeleter = { key -> + dao.delete(key.namespace.value, key.canonicalId()) + }, + namespaceDeleter = { namespace -> + dao.deleteNamespace(namespace.value) + }, + allDeleter = { + dao.deleteAll() + }, + ) +} + +// Turbine's 3s default would nest inside the 25s shadow. Raising the Turbine deadline above +// the shadow makes runTest the only effective timeout. +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds // strictly > TEST_TIMEOUT: the shadow must fire first + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } diff --git a/store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/Store6RoomSchemaMigrationTest.kt b/store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/Store6RoomSchemaMigrationTest.kt new file mode 100644 index 000000000..fb5aab6a7 --- /dev/null +++ b/store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/Store6RoomSchemaMigrationTest.kt @@ -0,0 +1,81 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.room + +import androidx.room3.migration.Migration +import androidx.sqlite.SQLiteConnection +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.testing.TestStoreMeta +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.time.Duration.Companion.seconds + +internal class Store6RoomSchemaMigrationTest { + @Test + fun manualMigration_matchesRoomExpectedSchema(): TestResult = runTest { + val path = newTempDatabasePath() + val key = RoomKitKey(StoreNamespace("migration"), "legacy") + val legacyDatabase = + configureTestDatabase(migrationV1DatabaseBuilder(path)) + try { + legacyDatabase + .kitRowDao() + .upsert(KitRowEntity(key.namespace.value, key.canonicalId(), "v1")) + } finally { + legacyDatabase.close() + } + + val migration = + object : Migration(1, 2) { + // Room 3's Migration.migrate is suspend; Store6RoomSchema.createTables stays + // non-suspend because androidx.sqlite 2.7.0's execSQL is still synchronous. + override suspend fun migrate(connection: SQLiteConnection) { + Store6RoomSchema.createTables(connection) + } + } + val migratedDatabase = + configureTestDatabase( + migrationV2DatabaseBuilder(path).addMigrations(migration), + ) + try { + val legacyRow = + migratedDatabase + .kitRowDao() + .row(key.namespace.value, key.canonicalId()) + .first() + assertEquals("v1", legacyRow?.payload) + + val bookkeeper = + RoomBookkeeper( + migratedDatabase, + migratedDatabase.store6BookkeeperDao(), + ) + bookkeeper.recordSuccess( + key, + TestStoreMeta(writtenAtEpochMillis = 1L, etag = "e1"), + ) + + val status = assertNotNull(bookkeeper.status(key)) + assertEquals(1L, status.meta?.writtenAtEpochMillis) + assertEquals("e1", status.meta?.etag) + assertEquals( + "v1", + migratedDatabase + .kitRowDao() + .row(key.namespace.value, key.canonicalId()) + .first() + ?.payload, + ) + } finally { + migratedDatabase.close() + } + } +} + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = 25.seconds, testBody = testBody) diff --git a/store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/TestDatabase.kt b/store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/TestDatabase.kt new file mode 100644 index 000000000..e27afc0c0 --- /dev/null +++ b/store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/TestDatabase.kt @@ -0,0 +1,133 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.room + +import androidx.room3.ConstructedBy +import androidx.room3.Dao +import androidx.room3.Database +import androidx.room3.Entity +import androidx.room3.Query +import androidx.room3.RoomDatabase +import androidx.room3.RoomDatabaseConstructor +import androidx.room3.Upsert +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow + +@Entity( + tableName = "kit_rows", + primaryKeys = ["namespace", "id"], +) +internal class KitRowEntity( + val namespace: String, + val id: String, + val payload: String, +) + +@Dao +internal interface KitRowDao { + @Query("SELECT * FROM kit_rows WHERE namespace = :namespace AND id = :id") + fun row(namespace: String, id: String): Flow + + @Upsert + suspend fun upsert(row: KitRowEntity) + + @Query("DELETE FROM kit_rows WHERE namespace = :namespace AND id = :id") + suspend fun delete(namespace: String, id: String) + + @Query("DELETE FROM kit_rows WHERE namespace = :namespace") + suspend fun deleteNamespace(namespace: String) + + @Query("DELETE FROM kit_rows") + suspend fun deleteAll() +} + +@Database( + entities = [ + KitRowEntity::class, + Store6BookkeepingEntity::class, + Store6WatermarkEntity::class, + ], + version = 1, + exportSchema = false, +) +@ConstructedBy(Store6RoomTestDatabaseConstructor::class) +internal abstract class Store6RoomTestDatabase : RoomDatabase() { + abstract fun kitRowDao(): KitRowDao + + abstract fun store6BookkeeperDao(): Store6BookkeeperDao +} + +@Database( + entities = [KitRowEntity::class], + version = 1, + exportSchema = false, +) +@ConstructedBy(MigrationV1DatabaseConstructor::class) +internal abstract class MigrationV1Database : RoomDatabase() { + abstract fun kitRowDao(): KitRowDao +} + +@Database( + entities = [ + KitRowEntity::class, + Store6BookkeepingEntity::class, + Store6WatermarkEntity::class, + ], + version = 2, + exportSchema = false, +) +@ConstructedBy(MigrationV2DatabaseConstructor::class) +internal abstract class MigrationV2Database : RoomDatabase() { + abstract fun kitRowDao(): KitRowDao + + abstract fun store6BookkeeperDao(): Store6BookkeeperDao +} + +@Suppress("NO_ACTUAL_FOR_EXPECT") +internal expect object Store6RoomTestDatabaseConstructor : + RoomDatabaseConstructor { + override fun initialize(): Store6RoomTestDatabase +} + +@Suppress("NO_ACTUAL_FOR_EXPECT") +internal expect object MigrationV1DatabaseConstructor : + RoomDatabaseConstructor { + override fun initialize(): MigrationV1Database +} + +@Suppress("NO_ACTUAL_FOR_EXPECT") +internal expect object MigrationV2DatabaseConstructor : + RoomDatabaseConstructor { + override fun initialize(): MigrationV2Database +} + +internal expect fun inMemoryTestDatabaseBuilder(): RoomDatabase.Builder + +internal expect fun fileTestDatabaseBuilder( + path: String, +): RoomDatabase.Builder + +internal expect fun migrationV1DatabaseBuilder( + path: String, +): RoomDatabase.Builder + +internal expect fun migrationV2DatabaseBuilder( + path: String, +): RoomDatabase.Builder + +internal expect fun newTempDatabasePath(): String + +internal fun createTestDatabase(): Store6RoomTestDatabase = + configureTestDatabase(inMemoryTestDatabaseBuilder()) + +internal fun openTestDatabase(path: String): Store6RoomTestDatabase = + configureTestDatabase(fileTestDatabaseBuilder(path)) + +internal fun configureTestDatabase( + builder: RoomDatabase.Builder, +): T = + builder + .setDriver(BundledSQLiteDriver()) + .setQueryCoroutineContext(Dispatchers.Default) + .build() diff --git a/store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/TestKeys.kt b/store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/TestKeys.kt new file mode 100644 index 000000000..23c85af62 --- /dev/null +++ b/store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/TestKeys.kt @@ -0,0 +1,11 @@ +package org.mobilenativefoundation.store6.room + +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace + +internal class RoomKitKey( + override val namespace: StoreNamespace, + private val id: String, +) : StoreKey { + override fun canonicalId(): String = id +} diff --git a/store6-room/src/jvmTest/kotlin/org/mobilenativefoundation/store6/room/TestDatabase.jvm.kt b/store6-room/src/jvmTest/kotlin/org/mobilenativefoundation/store6/room/TestDatabase.jvm.kt new file mode 100644 index 000000000..3643f7f3e --- /dev/null +++ b/store6-room/src/jvmTest/kotlin/org/mobilenativefoundation/store6/room/TestDatabase.jvm.kt @@ -0,0 +1,28 @@ +package org.mobilenativefoundation.store6.room + +import androidx.room3.Room +import androidx.room3.RoomDatabase +import java.io.File + +internal actual fun inMemoryTestDatabaseBuilder(): RoomDatabase.Builder = + Room.inMemoryDatabaseBuilder() + +internal actual fun fileTestDatabaseBuilder( + path: String, +): RoomDatabase.Builder = + Room.databaseBuilder(name = path) + +internal actual fun migrationV1DatabaseBuilder( + path: String, +): RoomDatabase.Builder = + Room.databaseBuilder(name = path) + +internal actual fun migrationV2DatabaseBuilder( + path: String, +): RoomDatabase.Builder = + Room.databaseBuilder(name = path) + +internal actual fun newTempDatabasePath(): String = + File.createTempFile("store6-room-", ".db") + .also { it.delete() } + .absolutePath diff --git a/store6-room/src/nativeTest/kotlin/org/mobilenativefoundation/store6/room/TestDatabase.native.kt b/store6-room/src/nativeTest/kotlin/org/mobilenativefoundation/store6/room/TestDatabase.native.kt new file mode 100644 index 000000000..1aa71d81e --- /dev/null +++ b/store6-room/src/nativeTest/kotlin/org/mobilenativefoundation/store6/room/TestDatabase.native.kt @@ -0,0 +1,33 @@ +package org.mobilenativefoundation.store6.room + +import androidx.room3.Room +import androidx.room3.RoomDatabase +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.toKString +import kotlin.random.Random +import platform.posix.getenv + +internal actual fun inMemoryTestDatabaseBuilder(): RoomDatabase.Builder = + Room.inMemoryDatabaseBuilder() + +internal actual fun fileTestDatabaseBuilder( + path: String, +): RoomDatabase.Builder = + Room.databaseBuilder(name = path) + +internal actual fun migrationV1DatabaseBuilder( + path: String, +): RoomDatabase.Builder = + Room.databaseBuilder(name = path) + +internal actual fun migrationV2DatabaseBuilder( + path: String, +): RoomDatabase.Builder = + Room.databaseBuilder(name = path) + +@OptIn(ExperimentalForeignApi::class) +internal actual fun newTempDatabasePath(): String { + val temporaryDirectory = getenv("TMPDIR")?.toKString()?.trimEnd('/') ?: "/tmp" + val randomHex = Random.nextLong().toULong().toString(radix = 16) + return "$temporaryDirectory/store6-room-$randomHex.db" +} diff --git a/store6-sqldelight/README.md b/store6-sqldelight/README.md new file mode 100644 index 000000000..51acdb32a --- /dev/null +++ b/store6-sqldelight/README.md @@ -0,0 +1,131 @@ +# Store6 SQLDelight adapter + +`store6-sqldelight` persists Store6 values and durable freshness metadata in one SQLDelight database. The adapter leaves your generated schema unchanged and creates its own four `store6_meta*` sidecar tables when it is constructed. + +The Store6 seam is a **freeze candidate, not frozen** — see [STABILITY.md](../STABILITY.md). + +## 15-minute existing-schema walkthrough + +### 0. Prerequisites + +Use Kotlin 2.3, SQLDelight 2.1.0, a JDK supported by your build, and a synchronous SQLDelight driver. This repository's executable sample uses JDK 11 bytecode and the JDBC SQLite driver. Until the snapshot is published remotely, publish `store6-core` and `store6-sqldelight` to Maven Local: + +```shell +./gradlew :store6-core:publishToMavenLocal :store6-sqldelight:publishToMavenLocal +``` + +On Linux, native executables also require the SQLite development package and `pkg-config`; the module resolves SQLite's host library directory through `pkg-config` when linking its native-driver tests. + +### 1. Add the adapter + +Keep the SQLDelight plugin and the driver for your platform, then add the Store6 adapter: + +```kotlin +repositories { + mavenLocal() + mavenCentral() +} + +dependencies { + implementation("org.mobilenativefoundation.store:store6-sqldelight:6.0.0-SNAPSHOT") + implementation("app.cash.sqldelight:sqlite-driver:2.1.0") // JVM sample +} +``` + +### 2. Keep your existing schema + +No Store6 columns, queries, or migrations belong in your `.sq` files. The sample starts with this ordinary user table: + +```sql +CREATE TABLE user ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL +); + +selectById: +SELECT * FROM user WHERE id = ?; + +upsert: +INSERT OR REPLACE INTO user(id, name, email) VALUES (?, ?, ?); + +deleteById: +DELETE FROM user WHERE id = ?; + +deleteAll: +DELETE FROM user; +``` + +Create or migrate that schema as usual, then construct the adapter. It idempotently creates `store6_meta_schema`, `store6_meta_sequence`, `store6_meta`, and `store6_meta_watermark` itself. The executable sample probes `sqlite_master` and calls `SampleDatabase.Schema.create(driver)` only for a new database. + +### 3. Wire generated queries to Store6 + +For this schema, `UserKey` implements `StoreKey`, uses `StoreNamespace("users")`, and returns its `id` from `canonicalId()`. `fakeApi` represents your network source and `fetches` is only a counter for the restart demonstration. + +```kotlin +val sot = SqlDelightSourceOfTruth( + driver = driver, transacter = db, + readQuery = { key -> db.userQueries.selectById(key.id) { id, name, email -> User(id, name, email) } }, + writeRow = { _, user -> db.userQueries.upsert(user.id, user.name, user.email) }, + deleteRow = { key -> db.userQueries.deleteById(key.id) }, + deleteNamespaceRows = { ns -> if (ns.value == "users") db.userQueries.deleteAll() }, + deleteAllRows = { db.userQueries.deleteAll() }, +) +val store = store { + fetcher { key -> fakeApi.user(key.id).also { fetches++ } } + persistence(sot) + bookkeeper(SqlDelightBookkeeper(driver, db)) +} +``` + +Three rules keep the boundary sound: + +1. **Round trip:** after `writeRow(key, value)` returns, `readQuery(key)` must return the equivalent `value`. +2. **One driver:** `driver`, `transacter`, every generated query, every mutation callback, and `SqlDelightBookkeeper` must address the same database through the same `SqlDriver`. That is what makes each value-and-meta update atomic. Construct both adapters before exposing the driver to concurrent work because sidecar schema setup is synchronous; afterward, adapter reads and transactions sharing that driver are serialized. +3. **Synchronous transactions:** `withTransaction` is for same-database statements that complete without suspension and remain on the calling thread. Do network, delays, dispatcher changes, and other asynchronous work before or after it. A block that genuinely suspends throws `IllegalStateException`, cancels its child job, and rolls the transaction back; non-cooperative suspension is unsupported. + +Adapter-owned writes and deletes notify matching active readers after commit, including equal-value rewrites. Reader signals are instance-scoped: direct SQL inside `withTransaction` and commits made through another adapter instance do not wake an already-active reader. A new collection still reads those external changes from the database. + +Use one logical Store per database and namespace set. Instances sharing a database also share the sidecar's monotone sequence and watermarks. + +### 4. Run twice + +The included sample stores its database at `build/sample.db`. Reset it for the first process, then run the same binary again without resetting: + +```shell +./gradlew :store6-sqldelight-sample:run --args=--reset +./gradlew :store6-sqldelight-sample:run +``` + +The first process fetches once and atomically persists the user plus durable metadata. The second process includes: + +```text +served from SQLDelight without a refetch (durable meta survived the restart): fetches=0 +``` + +Call `close()` when the Store is no longer needed. It is synchronous and idempotent, cancels active collectors, and makes every later operation fail immediately with `IllegalStateException("Store is closed.")`. A Store retains at most `maxIdleKeys` quiescent key engines (default 128); active collectors and in-flight work remain resident until they become quiescent. The sample uses one key. + +## Drivers and current limitations + +| Platform | SQLDelight dependency | Typical driver | Status | +| --- | --- | --- | --- | +| Android | `app.cash.sqldelight:android-driver:2.1.0` | `AndroidSqliteDriver` | Supported | +| Apple, Linux, Windows | `app.cash.sqldelight:native-driver:2.1.0` | native driver / `inMemoryDriver` | Supported | +| JVM desktop/server | `app.cash.sqldelight:sqlite-driver:2.1.0` | `JdbcSqliteDriver` | Supported | +| JS and Wasm | `app.cash.sqldelight:web-worker-driver:2.1.0` | web-worker driver | Not yet supported; this adapter currently requires synchronous drivers | + +The published KMP artifact covers the canonical Store6 targets, but driver-backed execution is limited to targets for which SQLDelight provides a synchronous driver. JS and Wasm remain compile-only for this adapter today. Each source-of-truth write stamps the sidecar's write time and clears its stored ETag. If Store later needs to fetch, the conditional request therefore carries no ETag until a later successful fetch records one. + +## Timing + +The walkthrough was measured on July 22, 2026 from a nonexistent consumer directory at `/private/tmp/store6-sqldelight-consumer-20260722-t7`. The consumer referenced only Maven Local coordinates, not repository projects. Commands used `/usr/bin/time -p`; the edit interval used epoch seconds immediately before and after creating the clean Gradle files, unchanged `User.sq`, and wiring. Machine: macOS 26.2 arm64, OpenJDK 17.0.18, Gradle 8.11.1. The shared Gradle dependency cache was warm, while the consumer had no `.gradle`, `build`, or database state. + +| Step | Measured wall time | +| --- | ---: | +| Publish `store6-core` and `store6-sqldelight` snapshots to Maven Local | 11.80 s | +| Create the clean consumer and add dependency, existing schema, and wiring | 40.00 s | +| First clean build and `run --args=--reset` | 9.96 s | +| Second process and durable-meta zero-refetch check | 0.96 s | +| **Total** | **62.72 s (1m 2.72s)** | + +This automated run is evidence that the documented path fits comfortably inside 15 minutes on this machine. diff --git a/store6-sqldelight/api/android/store6-sqldelight.api b/store6-sqldelight/api/android/store6-sqldelight.api new file mode 100644 index 000000000..ce38bb5a7 --- /dev/null +++ b/store6-sqldelight/api/android/store6-sqldelight.api @@ -0,0 +1,24 @@ +public final class org/mobilenativefoundation/store6/sqldelight/SqlDelightBookkeeper : org/mobilenativefoundation/store6/core/seam/Bookkeeper { + public fun (Lapp/cash/sqldelight/db/SqlDriver;Lapp/cash/sqldelight/Transacter;)V + public fun advanceGlobalStaleWatermark (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun advanceStaleWatermark (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun forget (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun forgetAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun forgetNamespace (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun markStale (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun recordFailure (Lorg/mobilenativefoundation/store6/core/StoreKey;JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun recordSuccess (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/StoreMeta;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun status (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class org/mobilenativefoundation/store6/sqldelight/SqlDelightSourceOfTruth : org/mobilenativefoundation/store6/core/seam/TransactionalSourceOfTruth { + public fun (Lapp/cash/sqldelight/db/SqlDriver;Lapp/cash/sqldelight/Transacter;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lorg/mobilenativefoundation/store6/core/seam/WallClock;Lkotlin/coroutines/CoroutineContext;)V + public synthetic fun (Lapp/cash/sqldelight/db/SqlDriver;Lapp/cash/sqldelight/Transacter;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lorg/mobilenativefoundation/store6/core/seam/WallClock;Lkotlin/coroutines/CoroutineContext;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun delete (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteNamespace (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun reader (Lorg/mobilenativefoundation/store6/core/StoreKey;)Lkotlinx/coroutines/flow/Flow; + public fun withTransaction (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun write (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + diff --git a/store6-sqldelight/api/jvm/store6-sqldelight.api b/store6-sqldelight/api/jvm/store6-sqldelight.api new file mode 100644 index 000000000..ce38bb5a7 --- /dev/null +++ b/store6-sqldelight/api/jvm/store6-sqldelight.api @@ -0,0 +1,24 @@ +public final class org/mobilenativefoundation/store6/sqldelight/SqlDelightBookkeeper : org/mobilenativefoundation/store6/core/seam/Bookkeeper { + public fun (Lapp/cash/sqldelight/db/SqlDriver;Lapp/cash/sqldelight/Transacter;)V + public fun advanceGlobalStaleWatermark (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun advanceStaleWatermark (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun forget (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun forgetAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun forgetNamespace (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun markStale (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun recordFailure (Lorg/mobilenativefoundation/store6/core/StoreKey;JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun recordSuccess (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/StoreMeta;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun status (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class org/mobilenativefoundation/store6/sqldelight/SqlDelightSourceOfTruth : org/mobilenativefoundation/store6/core/seam/TransactionalSourceOfTruth { + public fun (Lapp/cash/sqldelight/db/SqlDriver;Lapp/cash/sqldelight/Transacter;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lorg/mobilenativefoundation/store6/core/seam/WallClock;Lkotlin/coroutines/CoroutineContext;)V + public synthetic fun (Lapp/cash/sqldelight/db/SqlDriver;Lapp/cash/sqldelight/Transacter;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lorg/mobilenativefoundation/store6/core/seam/WallClock;Lkotlin/coroutines/CoroutineContext;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun delete (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteNamespace (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun reader (Lorg/mobilenativefoundation/store6/core/StoreKey;)Lkotlinx/coroutines/flow/Flow; + public fun withTransaction (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun write (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + diff --git a/store6-sqldelight/api/store6-sqldelight.klib.api b/store6-sqldelight/api/store6-sqldelight.klib.api new file mode 100644 index 000000000..bdc4091b9 --- /dev/null +++ b/store6-sqldelight/api/store6-sqldelight.klib.api @@ -0,0 +1,32 @@ +// Klib ABI Dump +// Targets: [iosArm64, iosSimulatorArm64, iosX64, js, linuxX64, macosArm64, mingwX64, tvosArm64, wasmJs, watchosArm64] +// Rendering settings: +// - Signature version: 2 +// - Show manifest properties: true +// - Show declarations: true + +// Library unique name: +final class <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> org.mobilenativefoundation.store6.sqldelight/SqlDelightSourceOfTruth : org.mobilenativefoundation.store6.core.seam/TransactionalSourceOfTruth<#A, #B> { // org.mobilenativefoundation.store6.sqldelight/SqlDelightSourceOfTruth|null[0] + constructor (app.cash.sqldelight.db/SqlDriver, app.cash.sqldelight/Transacter, kotlin/Function1<#A, app.cash.sqldelight/Query<#B>>, kotlin/Function2<#A, #B, kotlin/Unit>, kotlin/Function1<#A, kotlin/Unit>, kotlin/Function1, kotlin/Function0, org.mobilenativefoundation.store6.core.seam/WallClock? = ..., kotlin.coroutines/CoroutineContext = ...) // org.mobilenativefoundation.store6.sqldelight/SqlDelightSourceOfTruth.|(app.cash.sqldelight.db.SqlDriver;app.cash.sqldelight.Transacter;kotlin.Function1<1:0,app.cash.sqldelight.Query<1:1>>;kotlin.Function2<1:0,1:1,kotlin.Unit>;kotlin.Function1<1:0,kotlin.Unit>;kotlin.Function1;kotlin.Function0;org.mobilenativefoundation.store6.core.seam.WallClock?;kotlin.coroutines.CoroutineContext){}[0] + + final fun reader(#A): kotlinx.coroutines.flow/Flow<#B?> // org.mobilenativefoundation.store6.sqldelight/SqlDelightSourceOfTruth.reader|reader(1:0){}[0] + final suspend fun <#A1: kotlin/Any?> withTransaction(kotlin.coroutines/SuspendFunction0<#A1>): #A1 // org.mobilenativefoundation.store6.sqldelight/SqlDelightSourceOfTruth.withTransaction|withTransaction(kotlin.coroutines.SuspendFunction0<0:0>){0§}[0] + final suspend fun delete(#A) // org.mobilenativefoundation.store6.sqldelight/SqlDelightSourceOfTruth.delete|delete(1:0){}[0] + final suspend fun deleteAll() // org.mobilenativefoundation.store6.sqldelight/SqlDelightSourceOfTruth.deleteAll|deleteAll(){}[0] + final suspend fun deleteNamespace(org.mobilenativefoundation.store6.core/StoreNamespace) // org.mobilenativefoundation.store6.sqldelight/SqlDelightSourceOfTruth.deleteNamespace|deleteNamespace(org.mobilenativefoundation.store6.core.StoreNamespace){}[0] + final suspend fun write(#A, #B) // org.mobilenativefoundation.store6.sqldelight/SqlDelightSourceOfTruth.write|write(1:0;1:1){}[0] +} + +final class org.mobilenativefoundation.store6.sqldelight/SqlDelightBookkeeper : org.mobilenativefoundation.store6.core.seam/Bookkeeper { // org.mobilenativefoundation.store6.sqldelight/SqlDelightBookkeeper|null[0] + constructor (app.cash.sqldelight.db/SqlDriver, app.cash.sqldelight/Transacter) // org.mobilenativefoundation.store6.sqldelight/SqlDelightBookkeeper.|(app.cash.sqldelight.db.SqlDriver;app.cash.sqldelight.Transacter){}[0] + + final suspend fun advanceGlobalStaleWatermark() // org.mobilenativefoundation.store6.sqldelight/SqlDelightBookkeeper.advanceGlobalStaleWatermark|advanceGlobalStaleWatermark(){}[0] + final suspend fun advanceStaleWatermark(org.mobilenativefoundation.store6.core/StoreNamespace) // org.mobilenativefoundation.store6.sqldelight/SqlDelightBookkeeper.advanceStaleWatermark|advanceStaleWatermark(org.mobilenativefoundation.store6.core.StoreNamespace){}[0] + final suspend fun forget(org.mobilenativefoundation.store6.core/StoreKey) // org.mobilenativefoundation.store6.sqldelight/SqlDelightBookkeeper.forget|forget(org.mobilenativefoundation.store6.core.StoreKey){}[0] + final suspend fun forgetAll() // org.mobilenativefoundation.store6.sqldelight/SqlDelightBookkeeper.forgetAll|forgetAll(){}[0] + final suspend fun forgetNamespace(org.mobilenativefoundation.store6.core/StoreNamespace) // org.mobilenativefoundation.store6.sqldelight/SqlDelightBookkeeper.forgetNamespace|forgetNamespace(org.mobilenativefoundation.store6.core.StoreNamespace){}[0] + final suspend fun markStale(org.mobilenativefoundation.store6.core/StoreKey) // org.mobilenativefoundation.store6.sqldelight/SqlDelightBookkeeper.markStale|markStale(org.mobilenativefoundation.store6.core.StoreKey){}[0] + final suspend fun recordFailure(org.mobilenativefoundation.store6.core/StoreKey, kotlin/Long) // org.mobilenativefoundation.store6.sqldelight/SqlDelightBookkeeper.recordFailure|recordFailure(org.mobilenativefoundation.store6.core.StoreKey;kotlin.Long){}[0] + final suspend fun recordSuccess(org.mobilenativefoundation.store6.core/StoreKey, org.mobilenativefoundation.store6.core/StoreMeta) // org.mobilenativefoundation.store6.sqldelight/SqlDelightBookkeeper.recordSuccess|recordSuccess(org.mobilenativefoundation.store6.core.StoreKey;org.mobilenativefoundation.store6.core.StoreMeta){}[0] + final suspend fun status(org.mobilenativefoundation.store6.core/StoreKey): org.mobilenativefoundation.store6.core.seam/KeyStatus? // org.mobilenativefoundation.store6.sqldelight/SqlDelightBookkeeper.status|status(org.mobilenativefoundation.store6.core.StoreKey){}[0] +} diff --git a/store6-sqldelight/build.gradle.kts b/store6-sqldelight/build.gradle.kts new file mode 100644 index 000000000..7a1a3ec0f --- /dev/null +++ b/store6-sqldelight/build.gradle.kts @@ -0,0 +1,146 @@ +import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget +import org.jetbrains.kotlin.gradle.plugin.mpp.TestExecutable +import org.jetbrains.kotlin.gradle.targets.native.tasks.KotlinNativeTest +import org.jetbrains.kotlin.gradle.tasks.KotlinNativeLink +import org.jetbrains.kotlin.konan.target.Family +import org.jetbrains.kotlin.konan.target.HostManager + +plugins { + id("org.mobilenativefoundation.store.store6.multiplatform") +} + +// AC-5: byte-identical borrow of core's SoT-substitution-capable conformance suites (see decision doc). +// Include list re-pinned post-007: 10 files, unchanged by 007 (the wave's only core-test writer). +val borrowCoreConformanceSources by tasks.registering(Sync::class) { + description = "Copies core's SoT-substitution-capable conformance suites, unchanged, for AC-5 runs." + from(rootProject.layout.projectDirectory.dir("store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core")) { + include( + "StoreConformanceTest.kt", "StoreInvalidationConformanceTest.kt", + "EmissionSequenceConformanceTest.kt", "SingleFlightConformanceTest.kt", + "FreshnessPolicyConformanceTest.kt", "StoreRevalidationConformanceTest.kt", + "StoreInvalidationStressTest.kt", "SourceOfTruthSubstitutionTest.kt", + "TestKey.kt", "NamespacedTestKey.kt", + ) + } + into(layout.buildDirectory.dir("borrowedConformance/org/mobilenativefoundation/store6/core")) +} + +val linuxSqliteLibraryDirectory = providers.exec { + commandLine("pkg-config", "--variable=libdir", "sqlite3") +}.standardOutput.asText.map { output -> + output.trim().also { directory -> + require(directory.isNotEmpty()) { + "pkg-config returned an empty sqlite3 library directory" + } + } +} + +kotlin { + // Mirrors SQLDelight's linkSqlite() without applying its codegen/database plugin. + targets + .filterIsInstance() + .flatMap { it.binaries } + .forEach { binary -> + if ( + binary is TestExecutable && + HostManager.host.family == Family.LINUX && + binary.target.konanTarget.family == Family.LINUX + ) { + // The Linux test runs on the same host that supplies sqlite3. Kotlin/Native's + // glibc-2.19 sysroot cannot resolve the host library's newer indirect GLIBC + // versions at link time; the matching host loader resolves them at execution. + binary.linkerOpts( + "-L${linuxSqliteLibraryDirectory.get()}", + "-lsqlite3", + "--allow-shlib-undefined", + ) + } else { + binary.linkerOpts("-lsqlite3") + } + } + + sourceSets { + val commonMain by getting { + dependencies { + api(projects.store6Core) + api(libs.sqldelight.runtime) + implementation(libs.sqldelight.coroutines.extensions) + } + } + val commonTest by getting { + dependencies { + implementation(projects.store6Testing) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.turbine) + } + } + val commonSqlTest by creating { + dependsOn(commonTest) + kotlin.srcDir(borrowCoreConformanceSources) + } + val jvmTest by getting { + dependsOn(commonSqlTest) + dependencies { + implementation(libs.sqldelight.sqlite.driver) + } + } + val androidUnitTest by getting { + dependsOn(commonSqlTest) + dependencies { + implementation(libs.sqldelight.sqlite.driver) + } + } + val nativeSqlTest by creating { + dependsOn(commonSqlTest) + dependencies { + implementation(libs.sqldelight.native.driver) + } + } + listOf( + "iosX64Test", + "iosArm64Test", + "iosSimulatorArm64Test", + "macosArm64Test", + "linuxX64Test", + "mingwX64Test", + ).forEach { getByName(it).dependsOn(nativeSqlTest) } + } +} + +android { + namespace = "org.mobilenativefoundation.store6.sqldelight" +} + +tasks.withType().configureEach { + if (binary is TestExecutable) { + val targetFamily = binary.target.konanTarget.family + onlyIf("native SQLDelight test binaries require the current host SDK's sqlite3") { + val hostFamily = HostManager.host.family + targetFamily == hostFamily || + (hostFamily.isAppleFamily && targetFamily.isAppleFamily) + } + } +} + +tasks.withType().configureEach { + // KGP 2.1.21 can race native-test stdout/stderr handlers and corrupt Gradle + // 8.11.1's captured-output index after an executable succeeds. Keep JUnit + // testcase/failure XML, but do not read the affected output index. + reports.html.required.set(false) + reports.junitXml.apply { + isOutputPerTestCase = false + includeSystemOutLog.set(false) + includeSystemErrLog.set(false) + } + + doLast { + // allTests aggregates binary test results and would reopen the same index. + // Preserve results.bin while omitting only captured output from its report. + listOf("output.bin", "output.bin.idx").forEach { fileName -> + val outputFile = binaryResultsDirectory.file(fileName).get().asFile + check(!outputFile.exists() || outputFile.delete()) { + "Could not remove corrupt Gradle test-output file: $outputFile" + } + } + } +} diff --git a/store6-sqldelight/gradle.properties b/store6-sqldelight/gradle.properties new file mode 100644 index 000000000..32be628b5 --- /dev/null +++ b/store6-sqldelight/gradle.properties @@ -0,0 +1,3 @@ +VERSION_NAME=6.0.0-SNAPSHOT +POM_NAME=store6-sqldelight +POM_ARTIFACT_ID=store6-sqldelight diff --git a/store6-sqldelight/sample/build.gradle.kts b/store6-sqldelight/sample/build.gradle.kts new file mode 100644 index 000000000..0e25af8b2 --- /dev/null +++ b/store6-sqldelight/sample/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + id("org.jetbrains.kotlin.jvm") + application + alias(libs.plugins.sqldelight) +} + +kotlin { jvmToolchain(11) } + +dependencies { + implementation(projects.store6Sqldelight) + implementation(libs.sqldelight.sqlite.driver) +} + +sqldelight { + databases { + create("SampleDatabase") { + packageName.set("org.mobilenativefoundation.store6.sqldelight.sample.db") + } + } +} + +application { mainClass.set("org.mobilenativefoundation.store6.sqldelight.sample.MainKt") } diff --git a/store6-sqldelight/sample/src/main/kotlin/org/mobilenativefoundation/store6/sqldelight/sample/Main.kt b/store6-sqldelight/sample/src/main/kotlin/org/mobilenativefoundation/store6/sqldelight/sample/Main.kt new file mode 100644 index 000000000..5b86b88dc --- /dev/null +++ b/store6-sqldelight/sample/src/main/kotlin/org/mobilenativefoundation/store6/sqldelight/sample/Main.kt @@ -0,0 +1,106 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.sqldelight.sample + +import app.cash.sqldelight.db.QueryResult +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import java.io.File +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.store +import org.mobilenativefoundation.store6.sqldelight.SqlDelightBookkeeper +import org.mobilenativefoundation.store6.sqldelight.SqlDelightSourceOfTruth +import org.mobilenativefoundation.store6.sqldelight.sample.db.SampleDatabase +import org.mobilenativefoundation.store6.sqldelight.sample.db.User + +private class UserKey( + val id: String, +) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("users") + + override fun canonicalId(): String = id +} + +private object FakeApi { + suspend fun user(id: String): User = User(id, "User $id", "user$id@example.com") +} + +private val fakeApi = FakeApi + +public fun main(args: Array): Unit = + runBlocking { + val databaseFile = File("build/sample.db") + if ("--reset" in args) { + check(!databaseFile.exists() || databaseFile.delete()) { + "Could not reset ${databaseFile.absolutePath}" + } + } + databaseFile.parentFile.mkdirs() + + val driver = JdbcSqliteDriver("jdbc:sqlite:${databaseFile.absolutePath}") + if (!driver.hasUserSchema()) { + SampleDatabase.Schema.create(driver).value + } + val db = SampleDatabase(driver) + var fetches = 0 + + val sot = SqlDelightSourceOfTruth( + driver = driver, transacter = db, + readQuery = { key -> db.userQueries.selectById(key.id) { id, name, email -> User(id, name, email) } }, + writeRow = { _, user -> db.userQueries.upsert(user.id, user.name, user.email) }, + deleteRow = { key -> db.userQueries.deleteById(key.id) }, + deleteNamespaceRows = { ns -> if (ns.value == "users") db.userQueries.deleteAll() }, + deleteAllRows = { db.userQueries.deleteAll() }, + ) + val store = store { + fetcher { key -> fakeApi.user(key.id).also { fetches++ } } + persistence(sot) + bookkeeper(SqlDelightBookkeeper(driver, db)) + } + + val key = UserKey("42") + val startupFrames = + store.stream(key) + .take(if (db.userQueries.selectById(key.id).executeAsOneOrNull() == null) 2 else 1) + .toList() + .toMutableList() + if (startupFrames.size < 2) startupFrames += store.stream(key).first() + startupFrames.forEach { frame -> println(frame.describe()) } + val user = store.get(key) + println("get: ${user.name} <${user.email}>") + if (fetches == 0) { + println("served from SQLDelight without a refetch (durable meta survived the restart): fetches=0") + } else { + println("fetched once and persisted row + durable meta atomically: fetches=$fetches") + } + + // close() is synchronous and idempotent, cancels active collectors, and later operations + // fail immediately with IllegalStateException("Store is closed."). The engine retains at + // most maxIdleKeys (default 128) quiescent engines; this sample uses one key. + store.close() + store.close() + check(runCatching { store.stream(key) }.exceptionOrNull()?.message == "Store is closed.") + driver.close() + } + +private fun SqlDriver.hasUserSchema(): Boolean = + executeQuery( + identifier = null, + sql = "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'user'", + mapper = { cursor -> QueryResult.Value(cursor.next().value) }, + parameters = 0, + ).value + +private fun StoreResult.describe(): String = + when (this) { + is StoreResult.Loading -> "stream: Loading" + is StoreResult.Data -> "stream: Data(name=${value.name}, origin=$origin)" + is StoreResult.Revalidated -> "stream: Revalidated(age=$age)" + is StoreResult.Error -> "stream: Error($error)" + } diff --git a/store6-sqldelight/sample/src/main/sqldelight/org/mobilenativefoundation/store6/sqldelight/sample/db/User.sq b/store6-sqldelight/sample/src/main/sqldelight/org/mobilenativefoundation/store6/sqldelight/sample/db/User.sq new file mode 100644 index 000000000..bf968eee3 --- /dev/null +++ b/store6-sqldelight/sample/src/main/sqldelight/org/mobilenativefoundation/store6/sqldelight/sample/db/User.sq @@ -0,0 +1,21 @@ +CREATE TABLE user ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL +); + +selectById: +SELECT * +FROM user +WHERE id = ?; + +upsert: +INSERT OR REPLACE INTO user(id, name, email) +VALUES (?, ?, ?); + +deleteById: +DELETE FROM user +WHERE id = ?; + +deleteAll: +DELETE FROM user; diff --git a/store6-sqldelight/src/androidMain/AndroidManifest.xml b/store6-sqldelight/src/androidMain/AndroidManifest.xml new file mode 100644 index 000000000..8072ee00d --- /dev/null +++ b/store6-sqldelight/src/androidMain/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/store6-sqldelight/src/androidMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/ExecutionThread.android.kt b/store6-sqldelight/src/androidMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/ExecutionThread.android.kt new file mode 100644 index 000000000..c006e409d --- /dev/null +++ b/store6-sqldelight/src/androidMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/ExecutionThread.android.kt @@ -0,0 +1,4 @@ +package org.mobilenativefoundation.store6.sqldelight.internal + +@Suppress("DEPRECATION") +internal actual fun currentExecutionThreadId(): Long = Thread.currentThread().id diff --git a/store6-sqldelight/src/androidMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SystemEpochMillis.android.kt b/store6-sqldelight/src/androidMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SystemEpochMillis.android.kt new file mode 100644 index 000000000..66db40725 --- /dev/null +++ b/store6-sqldelight/src/androidMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SystemEpochMillis.android.kt @@ -0,0 +1,4 @@ +package org.mobilenativefoundation.store6.sqldelight.internal + +/** Returns the Android system wall clock in Unix epoch milliseconds. */ +internal actual fun currentEpochMillis(): Long = System.currentTimeMillis() diff --git a/store6-sqldelight/src/androidUnitTest/kotlin/org/mobilenativefoundation/store6/sqldelight/FreshHarness.android.kt b/store6-sqldelight/src/androidUnitTest/kotlin/org/mobilenativefoundation/store6/sqldelight/FreshHarness.android.kt new file mode 100644 index 000000000..863098909 --- /dev/null +++ b/store6-sqldelight/src/androidUnitTest/kotlin/org/mobilenativefoundation/store6/sqldelight/FreshHarness.android.kt @@ -0,0 +1,9 @@ +package org.mobilenativefoundation.store6.sqldelight + +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver + +internal actual fun freshHarness(): SqlHarness { + val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + TestSchema.create(driver).value + return SqlHarness(driver) +} diff --git a/store6-sqldelight/src/appleMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/ExecutionThread.apple.kt b/store6-sqldelight/src/appleMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/ExecutionThread.apple.kt new file mode 100644 index 000000000..0634119ff --- /dev/null +++ b/store6-sqldelight/src/appleMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/ExecutionThread.apple.kt @@ -0,0 +1,8 @@ +@file:OptIn(ObsoleteWorkersApi::class) + +package org.mobilenativefoundation.store6.sqldelight.internal + +import kotlin.native.concurrent.ObsoleteWorkersApi +import kotlin.native.concurrent.Worker + +internal actual fun currentExecutionThreadId(): Long = Worker.current.id.toLong() diff --git a/store6-sqldelight/src/appleMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SystemEpochMillis.apple.kt b/store6-sqldelight/src/appleMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SystemEpochMillis.apple.kt new file mode 100644 index 000000000..772904cd2 --- /dev/null +++ b/store6-sqldelight/src/appleMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SystemEpochMillis.apple.kt @@ -0,0 +1,8 @@ +package org.mobilenativefoundation.store6.sqldelight.internal + +import platform.Foundation.NSDate +import platform.Foundation.timeIntervalSince1970 + +/** Returns the Apple system wall clock in Unix epoch milliseconds. */ +internal actual fun currentEpochMillis(): Long = + (NSDate().timeIntervalSince1970 * 1_000.0).toLong() diff --git a/store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightBookkeeper.kt b/store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightBookkeeper.kt new file mode 100644 index 000000000..2dd32f583 --- /dev/null +++ b/store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightBookkeeper.kt @@ -0,0 +1,129 @@ +package org.mobilenativefoundation.store6.sqldelight + +import app.cash.sqldelight.Transacter +import app.cash.sqldelight.db.SqlDriver +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreMeta +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.core.seam.KeyStatus +import org.mobilenativefoundation.store6.sqldelight.internal.DriverAccess +import org.mobilenativefoundation.store6.sqldelight.internal.MetaSidecar +import kotlin.coroutines.cancellation.CancellationException + +/** + * Durable SQLDelight [Bookkeeper] backed by adapter-owned sidecar tables. + * + * Canonical identity is exclusively `(namespace.value, canonicalId())`. Every instance backed by + * the same database shares one monotone sequence across all keys, namespaces, stale marks, and + * watermarks. + * + * This adapter is the leaf lock owner: it acquires no Store locks and serializes each operation in + * a SQLite transaction. Suspend operations sharing a driver also share one bounded access gate + * with [SqlDelightSourceOfTruth]. Construct adapters before exposing the driver to concurrent work: + * sidecar schema setup is synchronous at construction time. Use one logical Store per + * database/namespace-set. Multiple logical stores sharing a database also share its monotone + * sequence as one store-local order. + * + * [recordSuccess], [recordFailure], and [forget] absorb non-cancellation storage failures. + * [status] treats a non-cancellation storage failure as unavailable status and returns null, so + * age is unknown and the caller treats the key as stale. Cancellation always propagates. + * Maintenance operations rethrow storage failures so their transactions remain exception-atomic. + */ +@ExperimentalStoreApi +@OptIn(DelicateStoreApi::class) +public class SqlDelightBookkeeper( + driver: SqlDriver, + private val transacter: Transacter, +) : Bookkeeper { + private val sidecar = MetaSidecar(driver, transacter) + private val driverAccess = DriverAccess.forDriver(driver) + + public override suspend fun recordSuccess(key: StoreKey, meta: StoreMeta) { + val ns = key.namespace + val canonicalId = key.canonicalId() + absorbing { + driverAccess.withAccess { + transacter.transaction { sidecar.recordSuccess(ns.value, canonicalId, meta) } + } + } + } + + public override suspend fun recordFailure(key: StoreKey, atEpochMillis: Long) { + val ns = key.namespace + val canonicalId = key.canonicalId() + absorbing { + driverAccess.withAccess { + transacter.transaction { + sidecar.recordFailure(ns.value, canonicalId, atEpochMillis) + } + } + } + } + + public override suspend fun status(key: StoreKey): KeyStatus? { + val ns = key.namespace + val canonicalId = key.canonicalId() + return try { + driverAccess.withAccess { + transacter.transactionWithResult { sidecar.readStatus(ns.value, canonicalId) } + } + } catch (exception: CancellationException) { + throw exception + } catch (_: Throwable) { + null + } + } + + public override suspend fun forget(key: StoreKey) { + val ns = key.namespace + val canonicalId = key.canonicalId() + absorbing { + driverAccess.withAccess { + transacter.transaction { sidecar.forget(ns.value, canonicalId) } + } + } + } + + public override suspend fun markStale(key: StoreKey) { + val ns = key.namespace + val canonicalId = key.canonicalId() + driverAccess.withAccess { + transacter.transaction { sidecar.markStale(ns.value, canonicalId) } + } + } + + public override suspend fun advanceStaleWatermark(namespace: StoreNamespace) { + driverAccess.withAccess { + transacter.transaction { sidecar.advanceNamespaceWatermark(namespace.value) } + } + } + + public override suspend fun advanceGlobalStaleWatermark() { + driverAccess.withAccess { + transacter.transaction { sidecar.advanceGlobalWatermark() } + } + } + + public override suspend fun forgetNamespace(namespace: StoreNamespace) { + driverAccess.withAccess { + transacter.transaction { sidecar.forgetNamespace(namespace.value) } + } + } + + public override suspend fun forgetAll() { + driverAccess.withAccess { + transacter.transaction { sidecar.forgetAll() } + } + } + + private suspend inline fun absorbing(crossinline block: suspend () -> Unit) { + try { + block() + } catch (exception: Throwable) { + if (exception is CancellationException) throw exception + } + } +} diff --git a/store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightSourceOfTruth.kt b/store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightSourceOfTruth.kt new file mode 100644 index 000000000..ef528ffdd --- /dev/null +++ b/store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightSourceOfTruth.kt @@ -0,0 +1,223 @@ +package org.mobilenativefoundation.store6.sqldelight + +import app.cash.sqldelight.Query +import app.cash.sqldelight.Transacter +import app.cash.sqldelight.TransactionCallbacks +import app.cash.sqldelight.db.SqlDriver +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.seam.TransactionalSourceOfTruth +import org.mobilenativefoundation.store6.core.seam.WallClock +import org.mobilenativefoundation.store6.sqldelight.internal.DriverAccess +import org.mobilenativefoundation.store6.sqldelight.internal.MetaSidecar +import org.mobilenativefoundation.store6.sqldelight.internal.SqlDelightSystemWallClock +import org.mobilenativefoundation.store6.sqldelight.internal.runNonSuspending +import kotlin.coroutines.CoroutineContext + +/** + * SQLDelight-backed source of truth that upholds the Store reader and mutation contract. Every + * [reader] first emits the current row or `null`, remains live without completing normally, and + * observes matching writes (including equal-value rewrites) and deletes. A normally returning + * mutation has applied its final row or absence and published the matching notification; a + * mutation that throws has not been applied. + * + * Every user-row mutation and its matching metadata mutation execute in one [Transacter] + * transaction, closing the durable user-row/sidecar atomicity boundary. The supplied query and + * mutation callbacks must use the same [SqlDriver] passed to this adapter so user rows, their + * transaction, and the sidecar remain coherent. + * + * Suspend operations sharing a driver are serialized through one bounded driver-access gate, + * including [SqlDelightBookkeeper] operations constructed with that driver. Construct adapters + * before exposing the driver to concurrent work: sidecar schema setup is synchronous at + * construction time. + * + * Live reader signals are instance-scoped: successful commits made through this instance publish + * to the affected key, namespace, or all active readers, including equal-value rewrites. Raw + * SQLDelight listener invalidations, direct SQL inside [withTransaction], and changes made through + * another adapter instance are not bridged into an already-active reader. Those external changes + * are read from the database by the first emission of every new collection. + * + * Write/read round-trip law: after [write] returns normally, a fresh [reader] collection for the + * same key first emits the written value. Constructor setup, mutation callbacks, and + * [withTransaction] statements execute on the calling context with no internal dispatching; + * reader query work uses [readContext], which defaults to [Dispatchers.Default]. A null + * `wallClock` selects the adapter's own system clock. + * + * Present behavior supports synchronous [Transacter] implementations only. A [withTransaction] + * block must complete without suspension and remain on its calling thread; a genuine suspension + * fails and rolls back the enclosing transaction. The adapter cancels the block's child job before + * reporting that failure, preventing cooperatively cancellable work from resuming after rollback. + * Non-cooperative suspension is unsupported. + * + * @param K the key type used to locate a row + * @param V the non-null row type + */ +@ExperimentalStoreApi +@OptIn(DelicateStoreApi::class) +public class SqlDelightSourceOfTruth( + driver: SqlDriver, + private val transacter: Transacter, + private val readQuery: (K) -> Query, + private val writeRow: (K, V) -> Unit, + private val deleteRow: (K) -> Unit, + private val deleteNamespaceRows: (StoreNamespace) -> Unit, + private val deleteAllRows: () -> Unit, + wallClock: WallClock? = null, + private val readContext: CoroutineContext = Dispatchers.Default, +) : TransactionalSourceOfTruth { + private val sidecar = MetaSidecar(driver, transacter) + private val driverAccess = DriverAccess.forDriver(driver) + private val clock = wallClock ?: SqlDelightSystemWallClock + private val activeReaders = MutableStateFlow>(emptyMap()) + + override fun reader(key: K): Flow = flow { + val identity = KeyIdentity(key) + val signal = acquireReader(identity) + try { + val query = readQuery(key) + emitAll( + signal.map { + driverAccess.withAccess { + withContext(readContext) { query.executeAsOneOrNull() } + } + }, + ) + } finally { + releaseReader(identity, signal) + } + } + + override suspend fun write( + key: K, + value: V, + ) { + driverAccess.withAccess { + transacter.transaction { + publishAfterCommit(MutationScope.Key(KeyIdentity(key))) + writeRow(key, value) + sidecar.stampWrite(key.namespace.value, key.canonicalId(), clock.nowEpochMillis()) + } + } + } + + override suspend fun delete(key: K) { + driverAccess.withAccess { + transacter.transaction { + publishAfterCommit(MutationScope.Key(KeyIdentity(key))) + deleteRow(key) + sidecar.deleteRow(key.namespace.value, key.canonicalId()) + } + } + } + + override suspend fun deleteNamespace(namespace: StoreNamespace) { + driverAccess.withAccess { + transacter.transaction { + publishAfterCommit(MutationScope.Namespace(namespace.value)) + deleteNamespaceRows(namespace) + sidecar.deleteNamespaceRows(namespace.value) + } + } + } + + override suspend fun deleteAll() { + driverAccess.withAccess { + transacter.transaction { + publishAfterCommit(MutationScope.All) + deleteAllRows() + sidecar.deleteAllRows() + } + } + } + + override suspend fun withTransaction(block: suspend () -> R): R = + driverAccess.withAccess { + val context = currentCoroutineContext() + transacter.transactionWithResult { runNonSuspending(context, block) } + } + + private fun acquireReader(key: KeyIdentity): MutableStateFlow { + while (true) { + val current = activeReaders.value + val existing = current[key] + if (existing != null) { + val updated = current + (key to existing.copy(references = existing.references + 1)) + if (activeReaders.compareAndSet(current, updated)) return existing.signal + } else { + val signal = MutableStateFlow(0L) + val updated = current + (key to ReaderEntry(signal, references = 1)) + if (activeReaders.compareAndSet(current, updated)) return signal + } + } + } + + private fun releaseReader( + key: KeyIdentity, + signal: MutableStateFlow, + ) { + while (true) { + val current = activeReaders.value + val existing = current[key] ?: return + if (existing.signal !== signal) return + val updated = + if (existing.references == 1L) { + current - key + } else { + current + (key to existing.copy(references = existing.references - 1)) + } + if (activeReaders.compareAndSet(current, updated)) return + } + } + + private fun TransactionCallbacks.publishAfterCommit(scope: MutationScope) { + afterCommit { + val readers = activeReaders.value + readers.forEach { (key, entry) -> + if (scope.affects(key)) entry.signal.update { it + 1L } + } + } + } + + private data class KeyIdentity( + val namespace: String, + val canonicalId: String, + ) { + constructor(key: StoreKey) : this(key.namespace.value, key.canonicalId()) + } + + private data class ReaderEntry( + val signal: MutableStateFlow, + val references: Long, + ) + + private sealed interface MutationScope { + fun affects(key: KeyIdentity): Boolean + + data class Key( + val identity: KeyIdentity, + ) : MutationScope { + override fun affects(key: KeyIdentity): Boolean = key == identity + } + + data class Namespace( + val value: String, + ) : MutationScope { + override fun affects(key: KeyIdentity): Boolean = key.namespace == value + } + + data object All : MutationScope { + override fun affects(key: KeyIdentity): Boolean = true + } + } +} diff --git a/store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/DriverAccess.kt b/store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/DriverAccess.kt new file mode 100644 index 000000000..78ed1611e --- /dev/null +++ b/store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/DriverAccess.kt @@ -0,0 +1,107 @@ +package org.mobilenativefoundation.store6.sqldelight.internal + +import app.cash.sqldelight.db.SqlDriver +import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.withContext +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext + +/** + * Serializes access to synchronous SQLDelight drivers without retaining driver instances. Drivers + * map onto a bounded, power-of-two set of mutexes; a collision can only over-serialize unrelated + * drivers. A live context lease makes same-driver adapter calls reentrant inside + * `withTransaction`, while job and execution-thread identity prevent launched or concurrently + * resumed continuations from escaping that lease. + */ +internal class DriverAccess private constructor( + private val stripe: Int, +) { + suspend fun withAccess(block: suspend () -> R): R { + val context = currentCoroutineContext() + val ownerJob = context[Job] + val callingThreadId = currentExecutionThreadId() + val heldAccess = context[HeldDriverAccess] + if (heldAccess?.owns(stripe, ownerJob, callingThreadId) == true) { + context.ensureActive() + return block() + } + + val gate = gates[stripe] + gate.lock() + val lease = Job() + return try { + context.ensureActive() + val acquiredThreadId = currentExecutionThreadId() + withContext( + HeldDriverAccess( + stripe = stripe, + ownerJob = ownerJob, + ownerThreadId = acquiredThreadId, + lease = lease, + parent = heldAccess, + ), + ) { + block() + } + } finally { + lease.cancel() + gate.unlock() + } + } + + companion object { + private const val STRIPE_COUNT = 64 + private val gates = List(STRIPE_COUNT) { Mutex() } + + fun forDriver(driver: SqlDriver): DriverAccess = + DriverAccess(driver.hashCode() and (STRIPE_COUNT - 1)) + } +} + +/** Rebinds live access leases to the child job used by a synchronous transaction block. */ +internal fun CoroutineContext.rebindDriverAccessOwner(ownerJob: Job): CoroutineContext { + val heldAccess = this[HeldDriverAccess] ?: return this + return this + heldAccess.withOwner(ownerJob) +} + +private class HeldDriverAccess( + private val stripe: Int, + private val ownerJob: Job?, + private val ownerThreadId: Long, + private val lease: Job, + private val parent: HeldDriverAccess?, +) : AbstractCoroutineContextElement(HeldDriverAccess) { + fun owns( + candidateStripe: Int, + candidateOwnerJob: Job?, + candidateThreadId: Long, + ): Boolean { + var candidate: HeldDriverAccess? = this + while (candidate != null) { + if ( + candidate.stripe == candidateStripe && + candidate.ownerJob === candidateOwnerJob && + candidate.ownerThreadId == candidateThreadId && + candidate.lease.isActive + ) { + return true + } + candidate = candidate.parent + } + return false + } + + fun withOwner(ownerJob: Job): HeldDriverAccess = + HeldDriverAccess( + stripe = stripe, + ownerJob = ownerJob, + ownerThreadId = ownerThreadId, + lease = lease, + parent = parent?.withOwner(ownerJob), + ) + + companion object Key : CoroutineContext.Key +} diff --git a/store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/ExecutionThread.kt b/store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/ExecutionThread.kt new file mode 100644 index 000000000..01364ff3d --- /dev/null +++ b/store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/ExecutionThread.kt @@ -0,0 +1,4 @@ +package org.mobilenativefoundation.store6.sqldelight.internal + +/** Stable identity of the thread or worker currently executing adapter code. */ +internal expect fun currentExecutionThreadId(): Long diff --git a/store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/MetaSidecar.kt b/store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/MetaSidecar.kt new file mode 100644 index 000000000..cb1bc0379 --- /dev/null +++ b/store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/MetaSidecar.kt @@ -0,0 +1,310 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.sqldelight.internal + +import app.cash.sqldelight.Transacter +import app.cash.sqldelight.db.QueryResult +import app.cash.sqldelight.db.SqlCursor +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.db.SqlPreparedStatement +import org.mobilenativefoundation.store6.core.StoreMeta +import org.mobilenativefoundation.store6.core.seam.KeyStatus + +/** + * Adapter-owned durable sidecar. Four tables are created and versioned by the adapter in + * the user's database with zero user schema changes. SQLite `user_version` is never touched + * because it belongs to the user's own schema. Present behavior is schema v1; the migration + * ladder exists but has no steps yet. + */ +internal class MetaSidecar( + private val driver: SqlDriver, + transacter: Transacter, +) { + init { + transacter.transaction { ensureSchema() } + } + + private fun ensureSchema() { + executeAll( + """CREATE TABLE IF NOT EXISTS store6_meta_schema( + id INTEGER NOT NULL PRIMARY KEY CHECK (id = 0), + version INTEGER NOT NULL)""", + """CREATE TABLE IF NOT EXISTS store6_meta_sequence( + id INTEGER NOT NULL PRIMARY KEY CHECK (id = 0), + next_value INTEGER NOT NULL)""", + """CREATE TABLE IF NOT EXISTS store6_meta( + namespace TEXT NOT NULL, + canonical_id TEXT NOT NULL, + written_at_epoch_millis INTEGER, + etag TEXT, + success_sequence INTEGER, + stale_sequence INTEGER, + failure_at_epoch_millis INTEGER, + consecutive_failures INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (namespace, canonical_id))""", + """CREATE TABLE IF NOT EXISTS store6_meta_watermark( + scope TEXT NOT NULL PRIMARY KEY, + sequence INTEGER NOT NULL)""", + "INSERT OR IGNORE INTO store6_meta_schema(id, version) VALUES (0, 1)", + "INSERT OR IGNORE INTO store6_meta_sequence(id, next_value) VALUES (0, 0)", + ) + + val version = queryLong("SELECT version FROM store6_meta_schema WHERE id = 0") + check(version != null && version <= SCHEMA_VERSION) { + "store6-sqldelight found durable-meta schema version $version in this database, but " + + "this adapter supports up to $SCHEMA_VERSION. Upgrade the store6-sqldelight " + + "dependency for this database, or restore the database." + } + // version < SCHEMA_VERSION: apply migration steps here when they exist (none at v1). + } + + // SoT-facing operations. Multi-statement methods require a caller-owned transaction. + + fun stampWrite(namespace: String, canonicalId: String, nowEpochMillis: Long) { + ensureMetaRow(namespace, canonicalId) + execute( + """UPDATE store6_meta + SET written_at_epoch_millis = ?, etag = NULL + WHERE namespace = ? AND canonical_id = ?""", + 3, + ) { + bindLong(0, nowEpochMillis) + bindString(1, namespace) + bindString(2, canonicalId) + } + } + + fun deleteRow(namespace: String, canonicalId: String) { + execute("DELETE FROM store6_meta WHERE namespace = ? AND canonical_id = ?", 2) { + bindString(0, namespace) + bindString(1, canonicalId) + } + } + + fun deleteNamespaceRows(namespace: String) { + execute("DELETE FROM store6_meta WHERE namespace = ?", 1) { + bindString(0, namespace) + } + } + + fun deleteAllRows() { + execute("DELETE FROM store6_meta") + } + + // Bookkeeper-facing operations. Multi-statement methods require a caller-owned transaction. + + fun nextSequence(): Long { + val current = + queryLong("SELECT next_value FROM store6_meta_sequence WHERE id = 0") ?: 0L + check(current < Long.MAX_VALUE) { "Bookkeeper sequence exhausted" } + execute("UPDATE store6_meta_sequence SET next_value = next_value + 1 WHERE id = 0") + return current + 1L + } + + fun recordSuccess(namespace: String, canonicalId: String, meta: StoreMeta) { + val sequence = nextSequence() + ensureMetaRow(namespace, canonicalId) + execute( + """UPDATE store6_meta + SET written_at_epoch_millis = ?, etag = ?, success_sequence = ?, + failure_at_epoch_millis = NULL, consecutive_failures = 0 + WHERE namespace = ? AND canonical_id = ?""", + 5, + ) { + bindLong(0, meta.writtenAtEpochMillis) + bindString(1, meta.etag) + bindLong(2, sequence) + bindString(3, namespace) + bindString(4, canonicalId) + } + } + + fun recordFailure(namespace: String, canonicalId: String, atEpochMillis: Long) { + ensureMetaRow(namespace, canonicalId) + execute( + """UPDATE store6_meta + SET failure_at_epoch_millis = ?, + consecutive_failures = consecutive_failures + 1 + WHERE namespace = ? AND canonical_id = ?""", + 3, + ) { + bindLong(0, atEpochMillis) + bindString(1, namespace) + bindString(2, canonicalId) + } + } + + fun markStale(namespace: String, canonicalId: String) { + val sequence = nextSequence() + ensureMetaRow(namespace, canonicalId) + execute( + """UPDATE store6_meta SET stale_sequence = ? + WHERE namespace = ? AND canonical_id = ?""", + 3, + ) { + bindLong(0, sequence) + bindString(1, namespace) + bindString(2, canonicalId) + } + } + + fun advanceNamespaceWatermark(namespace: String) { + advanceWatermark("ns:$namespace") + } + + fun advanceGlobalWatermark() { + advanceWatermark(GLOBAL_SCOPE) + } + + fun forget(namespace: String, canonicalId: String) { + deleteRow(namespace, canonicalId) + } + + fun forgetNamespace(namespace: String) { + deleteNamespaceRows(namespace) + } + + fun forgetAll() { + deleteAllRows() + } + + fun readStatus(namespace: String, canonicalId: String): KeyStatus? { + val row = queryMetaRow(namespace, canonicalId) + val coveringSequence = + maxOf( + row?.staleSequence ?: 0L, + queryLong( + "SELECT sequence FROM store6_meta_watermark WHERE scope = ?", + 1, + ) { bindString(0, "ns:$namespace") } ?: 0L, + queryLong( + "SELECT sequence FROM store6_meta_watermark WHERE scope = '$GLOBAL_SCOPE'", + ) ?: 0L, + ) + + return when { + row == null && coveringSequence == 0L -> null + row == null -> + KeyStatus( + meta = null, + lastSuccessSequence = null, + lastFailureAtEpochMillis = null, + consecutiveFailures = 0, + durablyStale = true, + ) + else -> + KeyStatus( + meta = + row.writtenAtEpochMillis?.let { writtenAt -> + SidecarStoreMeta(writtenAt, row.etag) + }, + lastSuccessSequence = row.successSequence, + lastFailureAtEpochMillis = row.failureAtEpochMillis, + consecutiveFailures = row.consecutiveFailures, + durablyStale = coveringSequence > (row.successSequence ?: 0L), + ) + } + } + + private fun advanceWatermark(scope: String) { + val sequence = nextSequence() + execute( + "INSERT OR IGNORE INTO store6_meta_watermark(scope, sequence) VALUES (?, ?)", + 2, + ) { + bindString(0, scope) + bindLong(1, sequence) + } + execute( + "UPDATE store6_meta_watermark SET sequence = ? WHERE scope = ?", + 2, + ) { + bindLong(0, sequence) + bindString(1, scope) + } + } + + private fun ensureMetaRow(namespace: String, canonicalId: String) { + execute( + """INSERT OR IGNORE INTO store6_meta(namespace, canonical_id) + VALUES (?, ?)""", + 2, + ) { + bindString(0, namespace) + bindString(1, canonicalId) + } + } + + private fun executeAll(vararg statements: String) { + statements.forEach(::execute) + } + + private fun execute( + sql: String, + parameters: Int = 0, + binders: SqlPreparedStatement.() -> Unit = {}, + ) { + driver.execute(null, sql, parameters, binders).value + } + + private fun queryLong( + sql: String, + parameters: Int = 0, + binders: SqlPreparedStatement.() -> Unit = {}, + ): Long? = + driver.executeQuery( + null, + sql, + { cursor -> + QueryResult.Value(if (cursor.next().value) cursor.getLong(0) else null) + }, + parameters, + binders, + ).value + + private fun queryMetaRow(namespace: String, canonicalId: String): MetaRow? = + driver.executeQuery( + null, + """SELECT written_at_epoch_millis, etag, success_sequence, stale_sequence, + failure_at_epoch_millis, consecutive_failures + FROM store6_meta WHERE namespace = ? AND canonical_id = ?""", + { cursor -> + QueryResult.Value( + if (cursor.next().value) cursor.toMetaRow() else null, + ) + }, + 2, + ) { + bindString(0, namespace) + bindString(1, canonicalId) + }.value + + private fun SqlCursor.toMetaRow(): MetaRow = + MetaRow( + writtenAtEpochMillis = getLong(0), + etag = getString(1), + successSequence = getLong(2), + staleSequence = getLong(3), + failureAtEpochMillis = getLong(4), + consecutiveFailures = getLong(5)!!.toInt(), + ) + + private data class MetaRow( + val writtenAtEpochMillis: Long?, + val etag: String?, + val successSequence: Long?, + val staleSequence: Long?, + val failureAtEpochMillis: Long?, + val consecutiveFailures: Int, + ) + + private class SidecarStoreMeta( + override val writtenAtEpochMillis: Long, + override val etag: String?, + ) : StoreMeta + + private companion object { + const val SCHEMA_VERSION = 1L + const val GLOBAL_SCOPE = "global" + } +} diff --git a/store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SynchronousTransactions.kt b/store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SynchronousTransactions.kt new file mode 100644 index 000000000..abcfb5d55 --- /dev/null +++ b/store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SynchronousTransactions.kt @@ -0,0 +1,50 @@ +package org.mobilenativefoundation.store6.sqldelight.internal + +import kotlinx.coroutines.Job +import kotlinx.coroutines.ensureActive +import kotlin.coroutines.Continuation +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.coroutines.intrinsics.COROUTINE_SUSPENDED +import kotlin.coroutines.intrinsics.startCoroutineUninterceptedOrReturn + +/** + * Runs a suspend block to synchronous completion inside a SQLDelight transaction without + * dispatching or parking the calling thread. The unintercepted coroutine intrinsic + * deterministically reports the first genuine suspension. If that happens, this throws and the + * enclosing transaction rolls back. The block receives a cancellable child job, which is cancelled + * before a suspension failure escapes so cooperative suspension cannot later resume work outside + * the transaction. + */ +internal fun runNonSuspending( + context: CoroutineContext = EmptyCoroutineContext, + block: suspend () -> R, +): R { + val blockJob = Job(context[Job]) + val blockContext = (context + blockJob).rebindDriverAccessOwner(blockJob) + try { + blockContext.ensureActive() + val result = + block.startCoroutineUninterceptedOrReturn( + Continuation(blockContext) { + // A completion delivered after COROUTINE_SUSPENDED belongs to a cancelled, + // rejected block. Its Result is deliberately ignored on the resuming thread. + }, + ) + if (result === COROUTINE_SUSPENDED) { + blockJob.cancel() + context.ensureActive() + throw IllegalStateException( + "withTransaction block suspended while executing against a synchronous SQLDelight " + + "driver for this store. Keep the block to synchronous same-database statements " + + "(this adapter's write/delete and other SQLDelight statements); move asynchronous " + + "work outside withTransaction.", + ) + } + blockContext.ensureActive() + @Suppress("UNCHECKED_CAST") + return result as R + } finally { + blockJob.cancel() + } +} diff --git a/store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SystemEpochMillis.kt b/store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SystemEpochMillis.kt new file mode 100644 index 000000000..b9beca2d4 --- /dev/null +++ b/store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SystemEpochMillis.kt @@ -0,0 +1,15 @@ +package org.mobilenativefoundation.store6.sqldelight.internal + +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.seam.WallClock + +/** The production clock backed by each platform's system clock. */ +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +internal val SqlDelightSystemWallClock: WallClock = + object : WallClock { + override fun nowEpochMillis(): Long = currentEpochMillis() + } + +/** Reads the platform's wall clock in milliseconds since the Unix epoch. */ +internal expect fun currentEpochMillis(): Long diff --git a/store6-sqldelight/src/commonSqlTest/kotlin/app/cash/turbine/BorrowedTurbineTimeout.kt b/store6-sqldelight/src/commonSqlTest/kotlin/app/cash/turbine/BorrowedTurbineTimeout.kt new file mode 100644 index 000000000..44ae3bea4 --- /dev/null +++ b/store6-sqldelight/src/commonSqlTest/kotlin/app/cash/turbine/BorrowedTurbineTimeout.kt @@ -0,0 +1,37 @@ +package app.cash.turbine + +import kotlinx.coroutines.flow.Flow +import org.mobilenativefoundation.store6.core.StoreResult +import kotlin.jvm.JvmName +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +private val SQLDELIGHT_BORROWED_TIMEOUT = 30.seconds + +internal object SqlDelightBorrowedTimeoutMarker + +/** Supplies the same timeout context to Turbine's member `testIn` extension. */ +@JvmName("sqlDelightBorrowedTurbineScope") +internal suspend fun turbineScope(validate: suspend TurbineContext.() -> Unit) { + turbineScope(timeout = SQLDELIGHT_BORROWED_TIMEOUT, validate = validate) +} + +/** + * Gives byte-identical borrowed Store-result scenarios a bounded real-driver timeout. SQLDelight + * query work uses non-test dispatchers, so Turbine's three-second wall-clock default is too narrow + * when a hosted build is compiling and testing multiple KMP targets. + */ +internal suspend fun Flow>.test( + timeout: Duration? = null, + name: String? = null, + @Suppress("UNUSED_PARAMETER") + marker: SqlDelightBorrowedTimeoutMarker = SqlDelightBorrowedTimeoutMarker, + validate: suspend TurbineTestContext>.() -> Unit, +) { + @Suppress("UNCHECKED_CAST") + val upstream = this as Flow + upstream.test(timeout = timeout ?: SQLDELIGHT_BORROWED_TIMEOUT, name = name) { + @Suppress("UNCHECKED_CAST") + validate(this as TurbineTestContext>) + } +} diff --git a/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/core/BorrowedSuiteSupport.kt b/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/core/BorrowedSuiteSupport.kt new file mode 100644 index 000000000..5821cd49e --- /dev/null +++ b/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/core/BorrowedSuiteSupport.kt @@ -0,0 +1,116 @@ +package org.mobilenativefoundation.store6.core + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import org.mobilenativefoundation.store6.core.seam.WallClock + +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +internal class FakeWallClock(var now: Long) : WallClock { + override fun nowEpochMillis(): Long = now +} + +@OptIn(ExperimentalStoreApi::class) +internal fun storeWith( + clock: WallClock? = null, + bookkeeper: Bookkeeper? = null, + configure: StoreBuilder.() -> Unit, +): Store = store { + clock?.let { wallClock(it) } + bookkeeper?.let { this.bookkeeper(it) } + configure() +} + +/** + * Public-seam-only counterpart to core's internal zero-config SoT for the borrowed compilation. + * Per-key versioned cells preserve equal-value rewrites and repeated deletes without waking + * unrelated readers. + */ +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +internal fun defaultConformanceSourceOfTruth(): SourceOfTruth = + BorrowedConformanceInMemorySourceOfTruth() + +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +private class BorrowedConformanceInMemorySourceOfTruth : + SourceOfTruth { + private class Cell( + val row: V?, + val version: Long, + ) + + private val lock = Mutex() + private val cells = HashMap>>() + + override fun reader(key: K): Flow = + flow { + emitAll(cellFor(key).map { cell -> cell.row }) + } + + override suspend fun write( + key: K, + value: V, + ) { + update(key, value) + } + + override suspend fun delete(key: K) { + update(key, null) + } + + override suspend fun deleteNamespace(namespace: StoreNamespace) { + lock.withLock { + cells.forEach { (key, cell) -> + if (key.namespace == namespace.value) cell.emitNull() + } + } + } + + override suspend fun deleteAll() { + lock.withLock { + cells.values.forEach { cell -> cell.emitNull() } + } + } + + private suspend fun cellFor(key: K): MutableStateFlow> { + val identity = ConformanceKey.from(key) + return lock.withLock { cellFor(identity) } + } + + private suspend fun update( + key: K, + row: V?, + ) { + val identity = ConformanceKey.from(key) + lock.withLock { + val cell = cellFor(identity) + val current = cell.value + cell.value = Cell(row = row, version = current.version + 1L) + } + } + + private fun cellFor(key: ConformanceKey): MutableStateFlow> = + cells.getOrPut(key) { + MutableStateFlow(Cell(row = null, version = 0L)) + } + + private fun MutableStateFlow>.emitNull() { + val current = value + value = Cell(row = null, version = current.version + 1L) + } +} + +/** + * Re-derivation of core's test-support shutdown for the borrowed compilation. This module cannot + * reference core internals (CI guard + module visibility), so settle is close() only: engine + * coroutines are cancelled but not joined. Cleanup-only; the borrowed scenarios' assertions never + * depend on joined shutdown. + */ +internal suspend fun Store<*, *>.closeAndSettleForTest() { + close() +} diff --git a/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightAtomicityTest.kt b/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightAtomicityTest.kt new file mode 100644 index 000000000..9520bce44 --- /dev/null +++ b/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightAtomicityTest.kt @@ -0,0 +1,249 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, +) + +package org.mobilenativefoundation.store6.sqldelight + +import app.cash.turbine.test +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import org.mobilenativefoundation.store6.core.StoreMeta +import kotlin.coroutines.cancellation.CancellationException +import kotlin.coroutines.resume +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +internal class SqlDelightAtomicityTest { + @Test + fun userStatementFailure_rollsBackValueAndSidecarStamp() = runTest { + withHarness { harness -> + val sourceOfTruth = + sourceOfTruth(harness) { key, value -> + harness.upsertRow(key.ns, key.id, value) + throw InjectedFailure("user statement failed") + } + + assertFailsWith { + sourceOfTruth.write(KEY_A, "value") + } + + assertValueAndSidecarAbsent(harness, KEY_A) + } + } + + @Test + fun sidecarStampFailure_rollsBackUserRow() = runTest { + withHarness { harness -> + val sourceOfTruth = sqlDelightTestSot(harness) + harness.executeRaw(FAIL_META_INSERT_TRIGGER) + + assertFails { + sourceOfTruth.write(KEY_A, "value") + } + assertValueAndSidecarAbsent(harness, KEY_A) + + harness.executeRaw("DROP TRIGGER store6_test_fail") + sourceOfTruth.write(KEY_A, "recovered") + + assertEquals("recovered", harness.selectRow(KEY_A.ns, KEY_A.id).executeAsOneOrNull()) + assertNotNull(harness.metaRow(KEY_A.ns, KEY_A.id)) + } + } + + @Test + fun withTransaction_blockFailure_rollsBackEchoAndRetire() = runTest { + withHarness { harness -> + val sourceOfTruth = sqlDelightTestSot(harness) + + assertFailsWith { + sourceOfTruth.withTransaction { + sourceOfTruth.write(KEY_A, "echo") + harness.upsertScratch(SCRATCH_ID, "retired") + throw InjectedFailure("transaction block failed") + } + } + + assertValueAndSidecarAbsent(harness, KEY_A) + assertNull(harness.selectScratch(SCRATCH_ID)) + } + } + + @Test + fun withTransaction_commit_appliesBothAndNotifiesOnce() = runTest { + withHarness { harness -> + val sourceOfTruth = sqlDelightTestSot(harness) + + sourceOfTruth.reader(KEY_A).test { + assertNull(awaitItem()) + + sourceOfTruth.withTransaction { + sourceOfTruth.write(KEY_A, "echo") + harness.upsertScratch(SCRATCH_ID, "retired") + expectNoEvents() + } + + assertEquals("echo", harness.selectRow(KEY_A.ns, KEY_A.id).executeAsOneOrNull()) + assertEquals("retired", harness.selectScratch(SCRATCH_ID)) + assertEquals("echo", awaitItem()) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun withTransaction_suspendingBlock_failsFastAndRollsBack() = runTest { + withHarness { harness -> + val sourceOfTruth = sqlDelightTestSot(harness) + var lateResume: CancellableContinuation? = null + + val failure = + assertFailsWith { + sourceOfTruth.withTransaction { + sourceOfTruth.write(KEY_A, "partial") + suspendCancellableCoroutine { continuation -> + lateResume = continuation + } + sourceOfTruth.write(KEY_A, "late") + } + } + + assertEquals(SUSPENDING_BLOCK_MESSAGE, failure.message) + assertValueAndSidecarAbsent(harness, KEY_A) + + lateResume!!.resume(Unit) + assertValueAndSidecarAbsent(harness, KEY_A) + } + } + + @Test + fun withTransaction_nestedSourceOfTruthAndBookkeeperCalls_areReentrant() = runTest { + withHarness { harness -> + val sourceOfTruth = sqlDelightTestSot(harness) + val bookkeeper = SqlDelightBookkeeper(harness.driver, harness.transacter) + val meta = + object : StoreMeta { + override val writtenAtEpochMillis: Long = 42L + override val etag: String = "v1" + } + + withTimeout(5_000) { + sourceOfTruth.withTransaction { + sourceOfTruth.write(KEY_A, "value") + bookkeeper.recordSuccess(KEY_A, meta) + } + } + + assertEquals("value", harness.selectRow(KEY_A.ns, KEY_A.id).executeAsOneOrNull()) + val status = assertNotNull(bookkeeper.status(KEY_A)) + assertEquals(42L, status.meta?.writtenAtEpochMillis) + assertEquals("v1", status.meta?.etag) + } + } + + @Test + fun withTransaction_launchedChildCannotEscapeDriverLease() = runTest { + withHarness { harness -> + val sourceOfTruth = sqlDelightTestSot(harness) + val releaseChild = CompletableDeferred() + var child: Job? = null + + sourceOfTruth.withTransaction { + child = + CoroutineScope(currentCoroutineContext()).launch( + start = CoroutineStart.UNDISPATCHED, + ) { + releaseChild.await() + sourceOfTruth.write(KEY_A, "escaped") + } + } + + releaseChild.complete(Unit) + child!!.join() + assertValueAndSidecarAbsent(harness, KEY_A) + } + } + + @Test + fun cancellationDuringMutation_isExceptionAtomic() = runTest { + withHarness { harness -> + val sourceOfTruth = + sourceOfTruth(harness) { key, value -> + harness.upsertRow(key.ns, key.id, value) + throw CancellationException("injected cancellation") + } + + val failure = + assertFailsWith { + sourceOfTruth.write(KEY_A, "value") + } + + assertEquals("injected cancellation", failure.message) + assertValueAndSidecarAbsent(harness, KEY_A) + } + } + + private fun sourceOfTruth( + harness: SqlHarness, + writeRow: (SqlTestKey, String) -> Unit, + ): SqlDelightSourceOfTruth = + SqlDelightSourceOfTruth( + driver = harness.driver, + transacter = harness.transacter, + readQuery = { key -> harness.selectRow(key.ns, key.id) }, + writeRow = writeRow, + deleteRow = { key -> harness.deleteRow(key.ns, key.id) }, + deleteNamespaceRows = { namespace -> harness.deleteNamespace(namespace.value) }, + deleteAllRows = harness::deleteAll, + ) + + private fun assertValueAndSidecarAbsent( + harness: SqlHarness, + key: SqlTestKey, + ) { + assertNull(harness.selectRow(key.ns, key.id).executeAsOneOrNull()) + assertNull(harness.metaRow(key.ns, key.id)) + } + + private suspend fun withHarness(block: suspend (SqlHarness) -> R): R { + val harness = freshHarness() + return try { + block(harness) + } finally { + harness.driver.close() + } + } + + private class InjectedFailure(message: String) : RuntimeException(message) + + private companion object { + val KEY_A = SqlTestKey(ns = "users", id = "a") + const val SCRATCH_ID = "journal-a" + + const val FAIL_META_INSERT_TRIGGER = + """CREATE TRIGGER store6_test_fail + BEFORE INSERT ON store6_meta + BEGIN + SELECT RAISE(ABORT, 'injected'); + END""" + + const val SUSPENDING_BLOCK_MESSAGE = + "withTransaction block suspended while executing against a synchronous SQLDelight " + + "driver for this store. Keep the block to synchronous same-database statements " + + "(this adapter's write/delete and other SQLDelight statements); move asynchronous " + + "work outside withTransaction." + } +} diff --git a/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightBookkeeperContractTest.kt b/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightBookkeeperContractTest.kt new file mode 100644 index 000000000..86fb75da0 --- /dev/null +++ b/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightBookkeeperContractTest.kt @@ -0,0 +1,13 @@ +@file:OptIn(ExperimentalCoroutinesApi::class, ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.sqldelight + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.testing.BookkeeperContractKit + +class SqlDelightBookkeeperContractTest : BookkeeperContractKit() { + override fun createBookkeeper(): Bookkeeper = + freshHarness().let { SqlDelightBookkeeper(it.driver, it.transacter) } +} diff --git a/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightBookkeeperRestartTest.kt b/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightBookkeeperRestartTest.kt new file mode 100644 index 000000000..c3aca5283 --- /dev/null +++ b/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightBookkeeperRestartTest.kt @@ -0,0 +1,89 @@ +package org.mobilenativefoundation.store6.sqldelight + +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreMeta +import org.mobilenativefoundation.store6.core.StoreNamespace +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) +class SqlDelightBookkeeperRestartTest { + @Test + fun durableAlgebra_survivesBookkeeperRecreation_onSameDriver() = runTest { + val harness = freshHarness() + try { + val markedKey = TestKey("marked", "key") + val namespaceWatermarkKey = TestKey("namespace-watermark", "never-seen") + val globalWatermarkKey = TestKey("global-watermark", "never-seen") + val laterSuccessKey = TestKey("success", "later") + val markedMeta = TestMeta(writtenAtEpochMillis = 10L, etag = "marked") + val namespaceMeta = TestMeta(writtenAtEpochMillis = 20L, etag = "namespace") + val laterMeta = TestMeta(writtenAtEpochMillis = 30L, etag = "later") + + val first = SqlDelightBookkeeper(harness.driver, harness.transacter) + first.advanceGlobalStaleWatermark() + first.recordSuccess(markedKey, markedMeta) + first.markStale(markedKey) + first.recordSuccess(namespaceWatermarkKey, namespaceMeta) + first.advanceStaleWatermark(namespaceWatermarkKey.namespace) + first.recordSuccess(laterSuccessKey, laterMeta) + + val second = SqlDelightBookkeeper(harness.driver, harness.transacter) + + val markedStatus = assertNotNull(second.status(markedKey)) + assertEquals(markedMeta.writtenAtEpochMillis, markedStatus.meta?.writtenAtEpochMillis) + assertEquals(markedMeta.etag, markedStatus.meta?.etag) + assertTrue(markedStatus.durablyStale) + + val namespaceStatus = assertNotNull(second.status(namespaceWatermarkKey)) + assertEquals( + namespaceMeta.writtenAtEpochMillis, + namespaceStatus.meta?.writtenAtEpochMillis, + ) + assertEquals(namespaceMeta.etag, namespaceStatus.meta?.etag) + assertTrue(namespaceStatus.durablyStale) + + val globalStatus = assertNotNull(second.status(globalWatermarkKey)) + assertNull(globalStatus.meta) + assertTrue(globalStatus.durablyStale) + + val laterStatus = assertNotNull(second.status(laterSuccessKey)) + assertEquals(laterMeta.writtenAtEpochMillis, laterStatus.meta?.writtenAtEpochMillis) + assertEquals(laterMeta.etag, laterStatus.meta?.etag) + assertFalse(laterStatus.durablyStale) + + val restartedMeta = TestMeta(writtenAtEpochMillis = 40L, etag = "restarted") + second.recordSuccess(namespaceWatermarkKey, restartedMeta) + val restartedStatus = assertNotNull(second.status(namespaceWatermarkKey)) + assertEquals( + restartedMeta.writtenAtEpochMillis, + restartedStatus.meta?.writtenAtEpochMillis, + ) + assertEquals(restartedMeta.etag, restartedStatus.meta?.etag) + assertFalse(restartedStatus.durablyStale) + } finally { + harness.driver.close() + } + } + + private class TestKey( + namespace: String, + private val id: String, + ) : StoreKey { + override val namespace = StoreNamespace(namespace) + + override fun canonicalId(): String = id + } + + private class TestMeta( + override val writtenAtEpochMillis: Long, + override val etag: String?, + ) : StoreMeta +} diff --git a/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightCrashWindowTest.kt b/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightCrashWindowTest.kt new file mode 100644 index 000000000..fdb1faa47 --- /dev/null +++ b/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightCrashWindowTest.kt @@ -0,0 +1,105 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, +) + +package org.mobilenativefoundation.store6.sqldelight + +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.store +import org.mobilenativefoundation.store6.testing.TestWallClock +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.hours + +internal class SqlDelightCrashWindowTest { + @Test + fun writeCommittedButRecordSuccessLost_ageIsKnownOnRestart() = runTest { + withHarness { harness -> + val clock = TestWallClock(startEpochMillis = WRITE_TIME) + val sourceOfTruth = sqlDelightTestSot(harness, clock) + var fetchCalls = 0 + val fetch: suspend (SqlTestKey) -> String = { + fetchCalls += 1 + "fetched-$fetchCalls" + } + + sourceOfTruth.write(KEY_A, "v1") + + val sidecarAdoptingStore = restartedStore(harness, clock, fetch) + try { + assertEquals( + "v1", + sidecarAdoptingStore.get(KEY_A, Freshness.MaxAge(1.hours)), + ) + assertEquals(0, fetchCalls) + } finally { + sidecarAdoptingStore.close() + } + + harness.executeRaw( + "DELETE FROM store6_meta WHERE namespace = 'users' AND canonical_id = 'a'", + ) + assertNull(harness.metaRow(KEY_A.ns, KEY_A.id)) + + val metaLessStore = restartedStore(harness, clock, fetch) + try { + assertEquals( + "fetched-1", + metaLessStore.get(KEY_A, Freshness.MaxAge(1.hours)), + ) + assertEquals(1, fetchCalls) + } finally { + metaLessStore.close() + } + } + } + + @Test + fun writeStamp_neverAssignsSuccessSequence() = runTest { + withHarness { harness -> + val clock = TestWallClock(startEpochMillis = WRITE_TIME) + val sourceOfTruth = sqlDelightTestSot(harness, clock) + val bookkeeper = SqlDelightBookkeeper(harness.driver, harness.transacter) + + bookkeeper.advanceStaleWatermark(KEY_A.namespace) + sourceOfTruth.write(KEY_A, "v1") + + val status = assertNotNull(bookkeeper.status(KEY_A)) + assertEquals(WRITE_TIME, status.meta?.writtenAtEpochMillis) + assertNull(status.lastSuccessSequence) + assertTrue(status.durablyStale) + } + } + + private fun restartedStore( + harness: SqlHarness, + clock: TestWallClock, + fetch: suspend (SqlTestKey) -> String, + ): Store = + store { + fetcher(fetch) + persistence(sqlDelightTestSot(harness, clock)) + bookkeeper(SqlDelightBookkeeper(harness.driver, harness.transacter)) + wallClock(clock) + } + + private suspend fun withHarness(block: suspend (SqlHarness) -> R): R { + val harness = freshHarness() + return try { + block(harness) + } finally { + harness.driver.close() + } + } + + private companion object { + val KEY_A = SqlTestKey(ns = "users", id = "a") + const val WRITE_TIME = 10_000L + } +} diff --git a/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightDurableMaintenanceTest.kt b/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightDurableMaintenanceTest.kt new file mode 100644 index 000000000..4f4035c33 --- /dev/null +++ b/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightDurableMaintenanceTest.kt @@ -0,0 +1,152 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, +) + +package org.mobilenativefoundation.store6.sqldelight + +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.store +import org.mobilenativefoundation.store6.testing.TestWallClock +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.hours + +internal class SqlDelightDurableMaintenanceTest { + @Test + fun invalidate_markIsObservedByFreshStoreUsingSharedCollaborators() = runTest { + withHarness { harness -> + var calls = 0 + val clock = TestWallClock(startEpochMillis = 100L) + fun buildStore(): Store = + restartedStore(harness, clock) { "v${++calls}" } + + val first = buildStore() + try { + assertEquals("v1", first.get(KEY_1)) + first.invalidate(KEY_1) + } finally { + first.close() + } + + val second = buildStore() + try { + assertEquals("v2", second.get(KEY_1, Freshness.MaxAge(1.hours))) + assertEquals(2, calls) + } finally { + second.close() + } + } + } + + @Test + fun invalidateNamespace_watermarkIsObservedForKeyUnseenByFreshStore() = runTest { + withHarness { harness -> + var calls = 0 + val clock = TestWallClock(startEpochMillis = 100L) + fun buildStore(): Store = + restartedStore(harness, clock) { "v${++calls}" } + + val first = buildStore() + try { + assertEquals("v1", first.get(KEY_A1)) + assertEquals("v2", first.get(KEY_A2)) + first.invalidateNamespace(StoreNamespace("a")) + } finally { + first.close() + } + + val second = buildStore() + try { + assertEquals("v3", second.get(KEY_A2, Freshness.MaxAge(1.hours))) + assertEquals(3, calls) + } finally { + second.close() + } + } + } + + @Test + fun invalidateAll_globalWatermarkIsObservedAcrossNamespacesByFreshStore() = runTest { + withHarness { harness -> + var calls = 0 + val clock = TestWallClock(startEpochMillis = 100L) + fun buildStore(): Store = + restartedStore(harness, clock) { "v${++calls}" } + + val first = buildStore() + try { + assertEquals("v1", first.get(KEY_A1)) + assertEquals("v2", first.get(KEY_B1)) + first.invalidateAll() + } finally { + first.close() + } + + val second = buildStore() + try { + assertEquals("v3", second.get(KEY_A1, Freshness.MaxAge(1.hours))) + assertEquals("v4", second.get(KEY_B1, Freshness.MaxAge(1.hours))) + assertEquals(4, calls) + } finally { + second.close() + } + } + } + + @Test + fun freshSidecarWithoutInvalidation_servesHydratedValueAfterFreshStoreStarts() = runTest { + withHarness { harness -> + var calls = 0 + val clock = TestWallClock(startEpochMillis = 100L) + fun buildStore(): Store = + restartedStore(harness, clock) { "v${++calls}" } + + val first = buildStore() + try { + assertEquals("v1", first.get(KEY_1)) + } finally { + first.close() + } + + val second = buildStore() + try { + assertEquals("v1", second.get(KEY_1, Freshness.MaxAge(1.hours))) + assertEquals(1, calls) + } finally { + second.close() + } + } + } + + private fun restartedStore( + harness: SqlHarness, + clock: TestWallClock, + fetch: suspend (SqlTestKey) -> String, + ): Store = + store { + fetcher(fetch) + persistence(sqlDelightTestSot(harness, clock)) + bookkeeper(SqlDelightBookkeeper(harness.driver, harness.transacter)) + wallClock(clock) + } + + private suspend fun withHarness(block: suspend (SqlHarness) -> R): R { + val harness = freshHarness() + return try { + block(harness) + } finally { + harness.driver.close() + } + } + + private companion object { + val KEY_1 = SqlTestKey(ns = "test", id = "1") + val KEY_A1 = SqlTestKey(ns = "a", id = "1") + val KEY_A2 = SqlTestKey(ns = "a", id = "2") + val KEY_B1 = SqlTestKey(ns = "b", id = "1") + } +} diff --git a/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightSchedulerPerturbationRuns.kt b/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightSchedulerPerturbationRuns.kt new file mode 100644 index 000000000..2b371c751 --- /dev/null +++ b/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightSchedulerPerturbationRuns.kt @@ -0,0 +1,94 @@ +@file:OptIn( + DelicateStoreApi::class, + ExperimentalStoreApi::class, +) + +package org.mobilenativefoundation.store6.sqldelight + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.yield +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.EmissionSequenceConformanceTest +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.FreshnessPolicyConformanceTest +import org.mobilenativefoundation.store6.core.StoreBuilder +import org.mobilenativefoundation.store6.core.StoreInvalidationConformanceTest +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import kotlin.test.AfterTest + +class StoreInvalidationConformanceAgainstHoppingSqlDelightSotTest : + StoreInvalidationConformanceTest() { + private val installer = BorrowedSotInstaller() + + override fun installSot(builder: StoreBuilder) { + builder.persistence(trackedSot(installer.createHopping())) + } + + @AfterTest + fun closeHarnesses() { + installer.closeAll() + } +} + +class EmissionSequenceConformanceAgainstHoppingSqlDelightSotTest : + EmissionSequenceConformanceTest() { + private val installer = BorrowedSotInstaller() + + override fun installSot(builder: StoreBuilder) { + builder.persistence(trackedSot(installer.createHopping())) + } + + @AfterTest + fun closeHarnesses() { + installer.closeAll() + } +} + +class FreshnessPolicyConformanceAgainstHoppingSqlDelightSotTest : + FreshnessPolicyConformanceTest() { + private val installer = BorrowedSotInstaller() + + override fun installSot(builder: StoreBuilder) { + builder.persistence(trackedSot(installer.createHopping())) + } + + @AfterTest + fun closeHarnesses() { + installer.closeAll() + } +} + +internal class PostCaptureReaderHopSourceOfTruth( + private val delegate: SourceOfTruth, +) : SourceOfTruth { + override fun reader(key: K): Flow = + delegate.reader(key) + .map { + yield() + it + }.flowOn(Dispatchers.Default) + + override suspend fun write( + key: K, + value: V, + ) { + delegate.write(key, value) + } + + override suspend fun delete(key: K) { + delegate.delete(key) + } + + override suspend fun deleteNamespace(namespace: StoreNamespace) { + delegate.deleteNamespace(namespace) + } + + override suspend fun deleteAll() { + delegate.deleteAll() + } +} diff --git a/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightSourceOfTruthContractTest.kt b/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightSourceOfTruthContractTest.kt new file mode 100644 index 000000000..aa0297761 --- /dev/null +++ b/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightSourceOfTruthContractTest.kt @@ -0,0 +1,123 @@ +package org.mobilenativefoundation.store6.sqldelight + +import app.cash.turbine.test +import app.cash.turbine.turbineScope +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.testing.SourceOfTruthContractKit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +@OptIn( + DelicateStoreApi::class, + ExperimentalStoreApi::class, + kotlinx.coroutines.ExperimentalCoroutinesApi::class, +) +internal class SqlDelightSourceOfTruthContractTest : SourceOfTruthContractKit() { + private val harness = freshHarness() + + override fun createSourceOfTruth() = sqlDelightTestSot(harness) + + override val keyA = SqlTestKey(ns = "users", id = "a") + override val keyB = SqlTestKey(ns = "users", id = "b") + override val keyOtherNamespace = SqlTestKey(ns = "teams", id = "a") + + override fun value(index: Int): String = "value-$index" + + @Test + fun nestedTransactionKeepsNotificationsKeyScoped(): TestResult = runTest { + val sourceOfTruth = createSourceOfTruth() + val matchingValue = value(1) + val otherValue = value(2) + sourceOfTruth.write(keyA, matchingValue) + sourceOfTruth.write(keyOtherNamespace, otherValue) + + turbineScope { + val matching = sourceOfTruth.reader(keyA).testIn(backgroundScope) + val other = sourceOfTruth.reader(keyOtherNamespace).testIn(backgroundScope) + try { + assertEquals(matchingValue, matching.awaitItem()) + assertEquals(otherValue, other.awaitItem()) + + sourceOfTruth.withTransaction { + sourceOfTruth.write(keyA, matchingValue) + } + + assertEquals(matchingValue, matching.awaitItem()) + runCurrent() + other.expectNoEvents() + } finally { + matching.cancelAndIgnoreRemainingEvents() + other.cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun rollbackPublishesNothingBeforeNextCommittedMutation(): TestResult = runTest { + val sourceOfTruth = createSourceOfTruth() + val existing = value(1) + val committed = value(2) + sourceOfTruth.write(keyOtherNamespace, existing) + + turbineScope { + val rolledBack = sourceOfTruth.reader(keyA).testIn(backgroundScope) + val other = sourceOfTruth.reader(keyOtherNamespace).testIn(backgroundScope) + try { + assertNull(rolledBack.awaitItem()) + assertEquals(existing, other.awaitItem()) + + assertFailsWith { + sourceOfTruth.withTransaction { + sourceOfTruth.write(keyA, value(3)) + throw IllegalStateException("roll back") + } + } + + runCurrent() + rolledBack.expectNoEvents() + other.expectNoEvents() + + sourceOfTruth.write(keyOtherNamespace, committed) + assertEquals(committed, other.awaitItem()) + runCurrent() + rolledBack.expectNoEvents() + } finally { + rolledBack.cancelAndIgnoreRemainingEvents() + other.cancelAndIgnoreRemainingEvents() + } + } + + sourceOfTruth.reader(keyA).test { + assertNull(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun cancellingOneSameKeyReaderKeepsTheOtherReaderLive(): TestResult = runTest { + val sourceOfTruth = createSourceOfTruth() + val current = value(1) + + turbineScope { + val first = sourceOfTruth.reader(keyA).testIn(backgroundScope) + val second = sourceOfTruth.reader(keyA).testIn(backgroundScope) + try { + assertNull(first.awaitItem()) + assertNull(second.awaitItem()) + first.cancelAndIgnoreRemainingEvents() + + sourceOfTruth.write(keyA, current) + assertEquals(current, second.awaitItem()) + } finally { + first.cancelAndIgnoreRemainingEvents() + second.cancelAndIgnoreRemainingEvents() + } + } + } +} diff --git a/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightSubstitutionRuns.kt b/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightSubstitutionRuns.kt new file mode 100644 index 000000000..b09a65ec4 --- /dev/null +++ b/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightSubstitutionRuns.kt @@ -0,0 +1,152 @@ +@file:OptIn(ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.sqldelight + +import kotlinx.coroutines.Dispatchers +import org.mobilenativefoundation.store6.core.EmissionSequenceConformanceTest +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.FreshnessPolicyConformanceTest +import org.mobilenativefoundation.store6.core.SingleFlightConformanceTest +import org.mobilenativefoundation.store6.core.StoreBuilder +import org.mobilenativefoundation.store6.core.StoreConformanceTest +import org.mobilenativefoundation.store6.core.StoreInvalidationConformanceTest +import org.mobilenativefoundation.store6.core.StoreInvalidationStressConformance +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreRevalidationConformance +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.test.AfterTest + +class StoreConformanceAgainstSqlDelightSotTest : StoreConformanceTest() { + private val installer = BorrowedSotInstaller() + + override fun installSot(builder: StoreBuilder) { + builder.persistence(trackedSot(installer.create())) + } + + @AfterTest + fun closeHarnesses() { + installer.closeAll() + } +} + +class StoreInvalidationConformanceAgainstSqlDelightSotTest : StoreInvalidationConformanceTest() { + private val installer = BorrowedSotInstaller() + + override fun installSot(builder: StoreBuilder) { + builder.persistence(trackedSot(installer.create())) + } + + @AfterTest + fun closeHarnesses() { + installer.closeAll() + } +} + +class EmissionSequenceConformanceAgainstSqlDelightSotTest : EmissionSequenceConformanceTest() { + private val installer = BorrowedSotInstaller() + + override fun installSot(builder: StoreBuilder) { + builder.persistence(trackedSot(installer.create())) + } + + @AfterTest + fun closeHarnesses() { + installer.closeAll() + } +} + +class SingleFlightConformanceAgainstSqlDelightSotTest : SingleFlightConformanceTest() { + private val installer = BorrowedSotInstaller() + + override fun installSot(builder: StoreBuilder) { + installer.install(builder) + } + + @AfterTest + fun closeHarnesses() { + installer.closeAll() + } +} + +class FreshnessPolicyConformanceAgainstSqlDelightSotTest : FreshnessPolicyConformanceTest() { + private val installer = BorrowedSotInstaller() + + override fun installSot(builder: StoreBuilder) { + builder.persistence(trackedSot(installer.create())) + } + + @AfterTest + fun closeHarnesses() { + installer.closeAll() + } +} + +class StoreRevalidationConformanceAgainstSqlDelightSotTest : StoreRevalidationConformance() { + private val installer = BorrowedSotInstaller() + + override fun installSot(builder: StoreBuilder) { + installer.install(builder) + } + + @AfterTest + fun closeHarnesses() { + installer.closeAll() + } +} + +class StoreInvalidationStressAgainstSqlDelightSotTest : StoreInvalidationStressConformance() { + private val installer = BorrowedSotInstaller() + + override fun installSot(builder: StoreBuilder) { + installer.install(builder) + } + + @AfterTest + fun closeHarnesses() { + installer.closeAll() + } +} + +internal class BorrowedSotInstaller { + private val harnesses = mutableListOf() + + fun install(builder: StoreBuilder) { + builder.persistence(create()) + } + + fun create(): SourceOfTruth { + // The byte-identical borrowed scenarios establish enrollment and publication with their own + // observable causal barriers. An extra test-fixture dispatcher hop can move the SQL read past + // those barriers under a saturated full-root build. Keep the real driver on the caller context + // for this borrowed suite only; production and adapter-owned fixtures retain Dispatchers.Default. + val harness = freshHarness().also { harnesses += it } + return sqlDelightTestSot( + harness = harness, + readContext = EmptyCoroutineContext, + ) + } + + fun createHopping(): SourceOfTruth { + val harness = freshHarness().also { harnesses += it } + return PostCaptureReaderHopSourceOfTruth( + sqlDelightTestSot( + harness = harness, + readContext = Dispatchers.Default, + ), + ) + } + + fun closeAll() { + var firstFailure: Throwable? = null + harnesses.forEach { harness -> + try { + harness.driver.close() + } catch (failure: Throwable) { + if (firstFailure == null) firstFailure = failure + } + } + harnesses.clear() + firstFailure?.let { throw it } + } +} diff --git a/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightTestFixtures.kt b/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightTestFixtures.kt new file mode 100644 index 000000000..2d39b82ac --- /dev/null +++ b/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightTestFixtures.kt @@ -0,0 +1,49 @@ +package org.mobilenativefoundation.store6.sqldelight + +import app.cash.sqldelight.Query +import kotlinx.coroutines.Dispatchers +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.seam.WallClock +import kotlin.coroutines.CoroutineContext + +internal data class SqlTestKey( + val ns: String, + val id: String, +) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace(ns) + + override fun canonicalId(): String = id +} + +/** + * Test-only generic bridge for SQLDelight contract fixtures. Test callers bind [V] to [String]; + * the query and value casts below are confined to this common SQL test source set. + */ +@OptIn(ExperimentalStoreApi::class) +internal fun sqlDelightTestSot( + harness: SqlHarness, + clock: WallClock? = null, + readContext: CoroutineContext = Dispatchers.Default, +): SqlDelightSourceOfTruth = + SqlDelightSourceOfTruth( + driver = harness.driver, + transacter = harness.transacter, + readQuery = { key -> + @Suppress("UNCHECKED_CAST") + (harness.selectRow(key.namespace.value, key.canonicalId()) as Query) + }, + writeRow = { key, value -> + harness.upsertRow(key.namespace.value, key.canonicalId(), value as String) + }, + deleteRow = { key -> + harness.deleteRow(key.namespace.value, key.canonicalId()) + }, + deleteNamespaceRows = { namespace -> + harness.deleteNamespace(namespace.value) + }, + deleteAllRows = harness::deleteAll, + wallClock = clock, + readContext = readContext, + ) diff --git a/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlHarness.kt b/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlHarness.kt new file mode 100644 index 000000000..f82ad6c85 --- /dev/null +++ b/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlHarness.kt @@ -0,0 +1,250 @@ +package org.mobilenativefoundation.store6.sqldelight + +import app.cash.sqldelight.Query +import app.cash.sqldelight.Transacter +import app.cash.sqldelight.TransacterImpl +import app.cash.sqldelight.db.AfterVersion +import app.cash.sqldelight.db.QueryResult +import app.cash.sqldelight.db.SqlCursor +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.db.SqlPreparedStatement +import app.cash.sqldelight.db.SqlSchema + +internal class SqlHarness( + val driver: SqlDriver, +) { + private val harnessTransacter = HarnessTransacter(driver) + + val transacter: Transacter = harnessTransacter + + fun selectRow(namespace: String, id: String): Query = + BoundRowQuery(driver, namespace, id) + + fun upsertRow(namespace: String, id: String, value: String) { + execute( + """INSERT INTO test_rows(namespace, id, value) VALUES (?, ?, ?) + ON CONFLICT(namespace, id) DO UPDATE SET value = excluded.value""", + 3, + ) { + bindString(0, namespace) + bindString(1, id) + bindString(2, value) + } + harnessTransacter.notifyRows() + } + + fun deleteRow(namespace: String, id: String) { + execute("DELETE FROM test_rows WHERE namespace = ? AND id = ?", 2) { + bindString(0, namespace) + bindString(1, id) + } + harnessTransacter.notifyRows() + } + + fun deleteNamespace(namespace: String) { + execute("DELETE FROM test_rows WHERE namespace = ?", 1) { + bindString(0, namespace) + } + harnessTransacter.notifyRows() + } + + fun deleteAll() { + execute("DELETE FROM test_rows") + harnessTransacter.notifyRows() + } + + fun selectScratch(id: String): String? = + queryOne("SELECT value FROM test_scratch WHERE id = ?", 1, { bindString(0, id) }) { + it.getString(0)!! + } + + fun upsertScratch(id: String, value: String) { + execute( + """INSERT INTO test_scratch(id, value) VALUES (?, ?) + ON CONFLICT(id) DO UPDATE SET value = excluded.value""", + 2, + ) { + bindString(0, id) + bindString(1, value) + } + } + + fun deleteScratch(id: String) { + execute("DELETE FROM test_scratch WHERE id = ?", 1) { bindString(0, id) } + } + + fun store6TableNames(): Set = + queryList( + """SELECT name FROM sqlite_master + WHERE type = 'table' AND name LIKE 'store6_meta%' + ORDER BY name""", + ) { it.getString(0)!! }.toSet() + + fun userVersion(): Long = queryOne("PRAGMA user_version") { it.getLong(0)!! }!! + + fun metaSchemaVersion(): Long? = + queryOne("SELECT version FROM store6_meta_schema WHERE id = 0") { it.getLong(0)!! } + + fun setMetaSchemaVersion(version: Long) { + execute("UPDATE store6_meta_schema SET version = ? WHERE id = 0", 1) { + bindLong(0, version) + } + } + + fun metaSequence(): Long? = + queryOne("SELECT next_value FROM store6_meta_sequence WHERE id = 0") { it.getLong(0)!! } + + fun setMetaSequence(value: Long) { + execute("UPDATE store6_meta_sequence SET next_value = ? WHERE id = 0", 1) { + bindLong(0, value) + } + } + + fun watermark(scope: String): Long? = + queryOne( + "SELECT sequence FROM store6_meta_watermark WHERE scope = ?", + 1, + { bindString(0, scope) }, + ) { it.getLong(0)!! } + + fun metaRow(namespace: String, id: String): MetaRow? = + queryOne( + """SELECT written_at_epoch_millis, etag, success_sequence, stale_sequence, + failure_at_epoch_millis, consecutive_failures + FROM store6_meta WHERE namespace = ? AND canonical_id = ?""", + 2, + { + bindString(0, namespace) + bindString(1, id) + }, + ) { cursor -> + MetaRow( + writtenAtEpochMillis = cursor.getLong(0), + etag = cursor.getString(1), + successSequence = cursor.getLong(2), + staleSequence = cursor.getLong(3), + failureAtEpochMillis = cursor.getLong(4), + consecutiveFailures = cursor.getLong(5)!!.toInt(), + ) + } + + fun executeRaw(sql: String) { + execute(sql) + } + + private fun execute( + sql: String, + parameters: Int = 0, + binders: SqlPreparedStatement.() -> Unit = {}, + ) { + driver.execute(null, sql, parameters, binders).value + } + + private fun queryOne( + sql: String, + mapper: (SqlCursor) -> T, + ): T? = queryOne(sql, 0, {}, mapper) + + private fun queryOne( + sql: String, + parameters: Int, + binders: SqlPreparedStatement.() -> Unit, + mapper: (SqlCursor) -> T, + ): T? = + driver.executeQuery( + null, + sql, + { cursor -> QueryResult.Value(if (cursor.next().value) mapper(cursor) else null) }, + parameters, + binders, + ).value + + private fun queryList( + sql: String, + mapper: (SqlCursor) -> T, + ): List = + driver.executeQuery( + null, + sql, + { cursor -> + val result = mutableListOf() + while (cursor.next().value) result += mapper(cursor) + QueryResult.Value(result) + }, + 0, + {}, + ).value +} + +internal data class MetaRow( + val writtenAtEpochMillis: Long?, + val etag: String?, + val successSequence: Long?, + val staleSequence: Long?, + val failureAtEpochMillis: Long?, + val consecutiveFailures: Int, +) + +private class HarnessTransacter(driver: SqlDriver) : TransacterImpl(driver) { + fun notifyRows() { + notifyQueries(-2) { emit -> emit("test_rows") } + } +} + +private class BoundRowQuery( + private val driver: SqlDriver, + private val namespace: String, + private val id: String, +) : Query({ cursor -> cursor.getString(0)!! }) { + override fun execute(mapper: (SqlCursor) -> QueryResult): QueryResult = + driver.executeQuery( + null, + "SELECT value FROM test_rows WHERE namespace = ? AND id = ?", + mapper, + 2, + ) { + bindString(0, namespace) + bindString(1, id) + } + + override fun addListener(listener: Listener) { + driver.addListener("test_rows", listener = listener) + } + + override fun removeListener(listener: Listener) { + driver.removeListener("test_rows", listener = listener) + } +} + +internal object TestSchema : SqlSchema> { + override val version: Long = 1L + + override fun create(driver: SqlDriver): QueryResult.Value { + driver.execute( + null, + """CREATE TABLE test_rows( + namespace TEXT NOT NULL, + id TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY(namespace, id))""", + 0, + ).value + driver.execute( + null, + """CREATE TABLE test_scratch( + id TEXT NOT NULL PRIMARY KEY, + value TEXT NOT NULL)""", + 0, + ).value + return QueryResult.Value(Unit) + } + + override fun migrate( + driver: SqlDriver, + oldVersion: Long, + newVersion: Long, + vararg callbacks: AfterVersion, + ): QueryResult.Value = QueryResult.Value(Unit) +} + +internal expect fun freshHarness(): SqlHarness diff --git a/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/MetaSidecarTest.kt b/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/MetaSidecarTest.kt new file mode 100644 index 000000000..3e3b717b1 --- /dev/null +++ b/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/MetaSidecarTest.kt @@ -0,0 +1,382 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.sqldelight.internal + +import app.cash.sqldelight.TransacterImpl +import app.cash.sqldelight.db.QueryResult +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.db.SqlPreparedStatement +import org.mobilenativefoundation.store6.core.StoreMeta +import org.mobilenativefoundation.store6.core.seam.KeyStatus +import org.mobilenativefoundation.store6.sqldelight.MetaRow +import org.mobilenativefoundation.store6.sqldelight.SqlHarness +import org.mobilenativefoundation.store6.sqldelight.freshHarness +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +internal class MetaSidecarTest { + @Test + fun androidMinSdkSqliteCompatibilityAvoidsPost39UpsertSyntax() = + withHarness { harness -> + val legacyDriver = RejectingPost39SyntaxDriver(harness.driver) + val transacter = TestTransacter(legacyDriver) + val sidecar = MetaSidecar(legacyDriver, transacter) + + transacter.transaction { + sidecar.stampWrite("users", "a", 10L) + sidecar.recordSuccess("users", "a", TestMeta(20L, "v1")) + sidecar.recordFailure("users", "a", 30L) + sidecar.markStale("users", "a") + sidecar.advanceNamespaceWatermark("users") + } + + assertEquals( + MetaRow( + writtenAtEpochMillis = 20L, + etag = "v1", + successSequence = 1L, + staleSequence = 2L, + failureAtEpochMillis = 30L, + consecutiveFailures = 1, + ), + harness.metaRow("users", "a"), + ) + assertEquals(3L, harness.metaSequence()) + assertEquals(3L, harness.watermark("ns:users")) + } + + @Test + fun schemaInitializationIsIdempotentAndLeavesUserVersionUntouched() = + withHarness { harness -> + harness.executeRaw("PRAGMA user_version = 73") + + MetaSidecar(harness.driver, harness.transacter) + MetaSidecar(harness.driver, harness.transacter) + + assertEquals( + setOf( + "store6_meta", + "store6_meta_schema", + "store6_meta_sequence", + "store6_meta_watermark", + ), + harness.store6TableNames(), + ) + assertEquals(1L, harness.metaSchemaVersion()) + assertEquals(0L, harness.metaSequence()) + assertEquals(73L, harness.userVersion()) + } + + @Test + fun newerStoredSchemaVersionFailsFastWithUpgradeOrRestoreDiagnostic() = + withHarness { harness -> + MetaSidecar(harness.driver, harness.transacter) + harness.setMetaSchemaVersion(2L) + + val failure = + assertFailsWith { + MetaSidecar(harness.driver, harness.transacter) + } + + assertEquals( + "store6-sqldelight found durable-meta schema version 2 in this database, but this " + + "adapter supports up to 1. Upgrade the store6-sqldelight dependency for this " + + "database, or restore the database.", + failure.message, + ) + } + + @Test + fun nextSequenceIsMonotoneAndRejectsLongMaxValue() = + withHarness { harness -> + val sidecar = MetaSidecar(harness.driver, harness.transacter) + + assertEquals(1L, harness.transactionWithResult { sidecar.nextSequence() }) + assertEquals(2L, harness.transactionWithResult { sidecar.nextSequence() }) + assertEquals(2L, harness.metaSequence()) + + harness.setMetaSequence(Long.MAX_VALUE) + val failure = + assertFailsWith { + harness.transactionWithResult { sidecar.nextSequence() } + } + assertEquals("Bookkeeper sequence exhausted", failure.message) + assertEquals(Long.MAX_VALUE, harness.metaSequence()) + } + + @Test + fun stampWriteChangesWrittenTimeAndNullsEtagWhilePreservingBookkeeping() = + withHarness { harness -> + val sidecar = MetaSidecar(harness.driver, harness.transacter) + harness.transaction { + sidecar.recordSuccess("users", "a", TestMeta(10L, "v1")) + sidecar.markStale("users", "a") + sidecar.recordFailure("users", "a", 30L) + sidecar.stampWrite("users", "a", 40L) + } + + assertEquals( + MetaRow( + writtenAtEpochMillis = 40L, + etag = null, + successSequence = 1L, + staleSequence = 2L, + failureAtEpochMillis = 30L, + consecutiveFailures = 1, + ), + harness.metaRow("users", "a"), + ) + assertEquals(2L, harness.metaSequence()) + } + + @Test + fun recordSuccessPreservesStaleMarkAdvancesSuccessAndClearsFailures() = + withHarness { harness -> + val sidecar = MetaSidecar(harness.driver, harness.transacter) + harness.transaction { + sidecar.markStale("users", "a") + sidecar.recordFailure("users", "a", 20L) + sidecar.recordSuccess("users", "a", TestMeta(30L, "v2")) + } + + assertEquals( + MetaRow( + writtenAtEpochMillis = 30L, + etag = "v2", + successSequence = 2L, + staleSequence = 1L, + failureAtEpochMillis = null, + consecutiveFailures = 0, + ), + harness.metaRow("users", "a"), + ) + val status = harness.transactionWithResult { sidecar.readStatus("users", "a") } + assertFalse(status!!.durablyStale) + } + + @Test + fun recordFailureIncrementsWithoutAllocatingASequence() = + withHarness { harness -> + val sidecar = MetaSidecar(harness.driver, harness.transacter) + harness.transaction { + sidecar.recordFailure("users", "a", 10L) + sidecar.recordFailure("users", "a", 20L) + } + + assertEquals(0L, harness.metaSequence()) + assertEquals( + StatusShape( + meta = null, + lastSuccessSequence = null, + lastFailureAtEpochMillis = 20L, + consecutiveFailures = 2, + durablyStale = false, + ), + harness.transactionWithResult { sidecar.readStatus("users", "a") }.toShape(), + ) + } + + @Test + fun readStatusUsesExactKeyNamespaceAndGlobalWatermarkAlgebra() { + val watermarkOnly = + StatusShape( + meta = null, + lastSuccessSequence = null, + lastFailureAtEpochMillis = null, + consecutiveFailures = 0, + durablyStale = true, + ) + val cases = + listOf( + StatusCase( + name = "absent row and no watermark", + setup = {}, + namespace = "users", + id = "a", + expected = null, + ), + StatusCase( + name = "key stale mark", + setup = { markStale("users", "a") }, + namespace = "users", + id = "a", + expected = watermarkOnly, + ), + StatusCase( + name = "matching namespace watermark", + setup = { advanceNamespaceWatermark("users") }, + namespace = "users", + id = "a", + expected = watermarkOnly, + ), + StatusCase( + name = "namespace watermark isolation", + setup = { advanceNamespaceWatermark("users") }, + namespace = "teams", + id = "a", + expected = null, + ), + StatusCase( + name = "global watermark coverage", + setup = { advanceGlobalWatermark() }, + namespace = "teams", + id = "a", + expected = watermarkOnly, + ), + StatusCase( + name = "later success outranks earlier watermarks", + setup = { + advanceNamespaceWatermark("users") + advanceGlobalWatermark() + recordSuccess("users", "a", TestMeta(50L, "fresh")) + }, + namespace = "users", + id = "a", + expected = + StatusShape( + meta = MetaShape(50L, "fresh"), + lastSuccessSequence = 3L, + lastFailureAtEpochMillis = null, + consecutiveFailures = 0, + durablyStale = false, + ), + ), + ) + + cases.forEach { case -> + withHarness { harness -> + val sidecar = MetaSidecar(harness.driver, harness.transacter) + harness.transaction { case.setup(sidecar) } + val actual = + harness.transactionWithResult { + sidecar.readStatus(case.namespace, case.id) + }.toShape() + assertEquals(case.expected, actual, case.name) + } + } + } + + @Test + fun rowDeletesNeverResetSequenceOrWatermarks() = + withHarness { harness -> + val sidecar = MetaSidecar(harness.driver, harness.transacter) + harness.transaction { + sidecar.recordSuccess("users", "a", TestMeta(1L, null)) + sidecar.recordSuccess("users", "b", TestMeta(2L, null)) + sidecar.recordSuccess("teams", "a", TestMeta(3L, null)) + sidecar.advanceGlobalWatermark() + sidecar.deleteRow("users", "a") + sidecar.deleteNamespaceRows("users") + sidecar.deleteAllRows() + } + + assertNull(harness.metaRow("users", "a")) + assertNull(harness.metaRow("users", "b")) + assertNull(harness.metaRow("teams", "a")) + assertEquals(4L, harness.metaSequence()) + assertEquals(4L, harness.watermark("global")) + } + + @Test + fun forgetOperationsDeleteRecordsButNeverResetWatermarksOrSequence() = + withHarness { harness -> + val sidecar = MetaSidecar(harness.driver, harness.transacter) + harness.transaction { + sidecar.recordSuccess("users", "a", TestMeta(1L, null)) + sidecar.recordSuccess("users", "b", TestMeta(2L, null)) + sidecar.recordSuccess("teams", "a", TestMeta(3L, null)) + sidecar.advanceNamespaceWatermark("users") + sidecar.forget("users", "a") + sidecar.forgetNamespace("users") + sidecar.forgetAll() + } + + assertNull(harness.metaRow("users", "a")) + assertNull(harness.metaRow("users", "b")) + assertNull(harness.metaRow("teams", "a")) + assertEquals(4L, harness.metaSequence()) + assertEquals(4L, harness.watermark("ns:users")) + } + + private data class TestMeta( + override val writtenAtEpochMillis: Long, + override val etag: String?, + ) : StoreMeta + + private data class StatusCase( + val name: String, + val setup: MetaSidecar.() -> Unit, + val namespace: String, + val id: String, + val expected: StatusShape?, + ) + + private data class MetaShape( + val writtenAtEpochMillis: Long, + val etag: String?, + ) + + private data class StatusShape( + val meta: MetaShape?, + val lastSuccessSequence: Long?, + val lastFailureAtEpochMillis: Long?, + val consecutiveFailures: Int, + val durablyStale: Boolean, + ) + + private fun KeyStatus?.toShape(): StatusShape? = + this?.let { status -> + StatusShape( + meta = + status.meta?.let { + MetaShape(it.writtenAtEpochMillis, it.etag) + }, + lastSuccessSequence = status.lastSuccessSequence, + lastFailureAtEpochMillis = status.lastFailureAtEpochMillis, + consecutiveFailures = status.consecutiveFailures, + durablyStale = status.durablyStale, + ) + } + + private inline fun withHarness(block: (SqlHarness) -> R): R { + val harness = freshHarness() + return try { + block(harness) + } finally { + harness.driver.close() + } + } + + private fun SqlHarness.transaction(block: () -> Unit) { + transacter.transaction { block() } + } + + private fun SqlHarness.transactionWithResult(block: () -> R): R = + transacter.transactionWithResult { block() } +} + +private class RejectingPost39SyntaxDriver( + private val delegate: SqlDriver, +) : SqlDriver by delegate { + override fun execute( + identifier: Int?, + sql: String, + parameters: Int, + binders: (SqlPreparedStatement.() -> Unit)?, + ): QueryResult { + if ("DO UPDATE" in sql) { + throw LegacySqliteSyntaxRejected( + "Android minSdk SQLite rejected post-3.9 SQL: DO UPDATE", + ) + } + return delegate.execute(identifier, sql, parameters, binders) + } +} + +private class TestTransacter(driver: SqlDriver) : TransacterImpl(driver) + +private class LegacySqliteSyntaxRejected(message: String) : IllegalStateException(message) diff --git a/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SynchronousTransactionsTest.kt b/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SynchronousTransactionsTest.kt new file mode 100644 index 000000000..53c40ee0c --- /dev/null +++ b/store6-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SynchronousTransactionsTest.kt @@ -0,0 +1,57 @@ +package org.mobilenativefoundation.store6.sqldelight.internal + +import kotlin.coroutines.suspendCoroutine +import kotlin.coroutines.resume +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertSame + +internal class SynchronousTransactionsTest { + @Test + fun synchronousSuccessReturnsValue() { + assertEquals(42, runNonSuspending { 42 }) + } + + @Test + fun inlineContinuationResumeStillCountsAsSynchronous() { + assertEquals( + 42, + runNonSuspending { + suspendCoroutine { continuation -> continuation.resume(42) } + }, + ) + } + + @Test + fun synchronousFailureIsRethrown() { + val expected = TestFailure() + + val actual = + assertFailsWith { + runNonSuspending { throw expected } + } + + assertSame(expected, actual) + } + + @Test + fun genuinelySuspendingBlockFailsFast() { + val failure = + assertFailsWith { + runNonSuspending { + suspendCoroutine { } + } + } + + assertEquals( + "withTransaction block suspended while executing against a synchronous SQLDelight " + + "driver for this store. Keep the block to synchronous same-database statements " + + "(this adapter's write/delete and other SQLDelight statements); move asynchronous " + + "work outside withTransaction.", + failure.message, + ) + } + + private class TestFailure : RuntimeException() +} diff --git a/store6-sqldelight/src/jsMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/ExecutionThread.js.kt b/store6-sqldelight/src/jsMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/ExecutionThread.js.kt new file mode 100644 index 000000000..2753d5d1d --- /dev/null +++ b/store6-sqldelight/src/jsMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/ExecutionThread.js.kt @@ -0,0 +1,4 @@ +package org.mobilenativefoundation.store6.sqldelight.internal + +// This adapter has no JavaScript driver lane, and the current JS runtime is single-threaded. +internal actual fun currentExecutionThreadId(): Long = 0L diff --git a/store6-sqldelight/src/jsMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SystemEpochMillis.js.kt b/store6-sqldelight/src/jsMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SystemEpochMillis.js.kt new file mode 100644 index 000000000..7b471edea --- /dev/null +++ b/store6-sqldelight/src/jsMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SystemEpochMillis.js.kt @@ -0,0 +1,6 @@ +package org.mobilenativefoundation.store6.sqldelight.internal + +import kotlin.js.Date + +/** Returns the JavaScript system wall clock in Unix epoch milliseconds. */ +internal actual fun currentEpochMillis(): Long = Date.now().toLong() diff --git a/store6-sqldelight/src/jvmMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/ExecutionThread.jvm.kt b/store6-sqldelight/src/jvmMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/ExecutionThread.jvm.kt new file mode 100644 index 000000000..c006e409d --- /dev/null +++ b/store6-sqldelight/src/jvmMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/ExecutionThread.jvm.kt @@ -0,0 +1,4 @@ +package org.mobilenativefoundation.store6.sqldelight.internal + +@Suppress("DEPRECATION") +internal actual fun currentExecutionThreadId(): Long = Thread.currentThread().id diff --git a/store6-sqldelight/src/jvmMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SystemEpochMillis.jvm.kt b/store6-sqldelight/src/jvmMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SystemEpochMillis.jvm.kt new file mode 100644 index 000000000..ff66749ea --- /dev/null +++ b/store6-sqldelight/src/jvmMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SystemEpochMillis.jvm.kt @@ -0,0 +1,4 @@ +package org.mobilenativefoundation.store6.sqldelight.internal + +/** Returns the JVM system wall clock in Unix epoch milliseconds. */ +internal actual fun currentEpochMillis(): Long = System.currentTimeMillis() diff --git a/store6-sqldelight/src/jvmTest/kotlin/org/mobilenativefoundation/store6/sqldelight/DriverAccessConcurrencyJvmTest.kt b/store6-sqldelight/src/jvmTest/kotlin/org/mobilenativefoundation/store6/sqldelight/DriverAccessConcurrencyJvmTest.kt new file mode 100644 index 000000000..58fed520d --- /dev/null +++ b/store6-sqldelight/src/jvmTest/kotlin/org/mobilenativefoundation/store6/sqldelight/DriverAccessConcurrencyJvmTest.kt @@ -0,0 +1,347 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, +) + +package org.mobilenativefoundation.store6.sqldelight + +import app.cash.sqldelight.Query +import app.cash.sqldelight.db.QueryResult +import app.cash.sqldelight.db.SqlCursor +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import kotlinx.coroutines.withContext +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.intrinsics.COROUTINE_SUSPENDED +import kotlin.coroutines.intrinsics.suspendCoroutineUninterceptedOrReturn +import kotlin.coroutines.resume +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +internal class DriverAccessConcurrencyJvmTest { + @Test + fun concurrentCrossKeyWrites_shareOneDriverGate() = runTest { + withHarness { harness -> + val firstEntered = CountDownLatch(1) + val releaseFirst = CountDownLatch(1) + val secondStarted = CountDownLatch(1) + val secondEntered = CountDownLatch(1) + val sourceOfTruth = + sourceOfTruth(harness) { key, value -> + when (key) { + KEY_A -> { + harness.upsertRow(key.ns, key.id, value) + firstEntered.countDown() + assertTrue(releaseFirst.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) + } + KEY_B -> { + secondEntered.countDown() + harness.upsertRow(key.ns, key.id, value) + } + } + } + + val firstWrite = async(Dispatchers.Default) { sourceOfTruth.write(KEY_A, "alpha") } + try { + assertTrue(firstEntered.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) + val secondWrite = + async(Dispatchers.Default) { + secondStarted.countDown() + sourceOfTruth.write(KEY_B, "bravo") + } + + assertTrue(secondStarted.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) + assertFalse(secondEntered.await(OVERLAP_PROBE_MILLIS, TimeUnit.MILLISECONDS)) + releaseFirst.countDown() + firstWrite.await() + secondWrite.await() + } finally { + releaseFirst.countDown() + } + + assertEquals("alpha", harness.selectRow(KEY_A.ns, KEY_A.id).executeAsOneOrNull()) + assertEquals("bravo", harness.selectRow(KEY_B.ns, KEY_B.id).executeAsOneOrNull()) + } + } + + @Test + fun sourceOfTruthAndBookkeeper_shareOneDriverGate() = runTest { + withHarness { harness -> + val writeEntered = CountDownLatch(1) + val releaseWrite = CountDownLatch(1) + val maintenanceStarted = CountDownLatch(1) + val maintenanceCompleted = CountDownLatch(1) + val sourceOfTruth = + sourceOfTruth(harness) { key, value -> + harness.upsertRow(key.ns, key.id, value) + writeEntered.countDown() + assertTrue(releaseWrite.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) + } + val bookkeeper = SqlDelightBookkeeper(harness.driver, harness.transacter) + + val write = async(Dispatchers.Default) { sourceOfTruth.write(KEY_A, "alpha") } + try { + assertTrue(writeEntered.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) + val maintenance = + async(Dispatchers.Default) { + maintenanceStarted.countDown() + bookkeeper.advanceGlobalStaleWatermark() + maintenanceCompleted.countDown() + } + + assertTrue(maintenanceStarted.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) + assertFalse( + maintenanceCompleted.await(OVERLAP_PROBE_MILLIS, TimeUnit.MILLISECONDS), + ) + releaseWrite.countDown() + write.await() + maintenance.await() + } finally { + releaseWrite.countDown() + } + + assertNotNull(harness.watermark("global")) + } + } + + @Test + fun readerQueryExecution_waitsForSameDriverWrite() = runTest { + withHarness { harness -> + val writeEntered = CountDownLatch(1) + val releaseWrite = CountDownLatch(1) + val readerStarted = CountDownLatch(1) + val queryExecuted = CountDownLatch(1) + val sourceOfTruth = + sourceOfTruth( + harness = harness, + readQuery = { key -> + ProbedQuery(harness.selectRow(key.ns, key.id), queryExecuted) + }, + ) { key, value -> + harness.upsertRow(key.ns, key.id, value) + writeEntered.countDown() + assertTrue(releaseWrite.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) + } + + val write = async(Dispatchers.Default) { sourceOfTruth.write(KEY_A, "alpha") } + try { + assertTrue(writeEntered.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) + val read = + async(Dispatchers.Default) { + readerStarted.countDown() + sourceOfTruth.reader(KEY_B).first() + } + + assertTrue(readerStarted.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) + assertFalse(queryExecuted.await(OVERLAP_PROBE_MILLIS, TimeUnit.MILLISECONDS)) + releaseWrite.countDown() + write.await() + assertNull(read.await()) + assertTrue(queryExecuted.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) + } finally { + releaseWrite.countDown() + } + } + } + + @Test + fun foreignThreadResumption_cannotEnterDriverBeforeSuspensionIsRejected() = runTest { + withHarness { harness -> + val foreignWriteEntered = CountDownLatch(1) + val workerFinished = CountDownLatch(1) + val sourceOfTruth = + sourceOfTruth(harness) { key, value -> + foreignWriteEntered.countDown() + harness.upsertRow(key.ns, key.id, value) + } + + val failure = + assertFailsWith { + sourceOfTruth.withTransaction { + suspendCoroutineUninterceptedOrReturn { continuation -> + // Resume on another thread before this suspension callback returns. + // A context-only lease lets that thread enter writeRow concurrently. + // Thread affinity must make it wait for the driver gate instead. + Thread( + { + try { + continuation.resume(Unit) + } finally { + workerFinished.countDown() + } + }, + "store6-sqldelight-forced-dispatch", + ).apply { + isDaemon = true + start() + } + foreignWriteEntered.await( + OVERLAP_PROBE_MILLIS, + TimeUnit.MILLISECONDS, + ) + COROUTINE_SUSPENDED + } + sourceOfTruth.write(KEY_A, "foreign") + } + } + + assertEquals(SUSPENDING_BLOCK_MESSAGE, failure.message) + assertFalse(foreignWriteEntered.await(50L, TimeUnit.MILLISECONDS)) + assertTrue(workerFinished.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) + assertNull(harness.selectRow(KEY_A.ns, KEY_A.id).executeAsOneOrNull()) + assertNull(harness.metaRow(KEY_A.ns, KEY_A.id)) + } + } + + @Test + fun contendedGate_nestedReentryUsesAcquisitionThread() = runTest { + withHarness { harness -> + val holdingWriteEntered = CountDownLatch(1) + val releaseHoldingWrite = CountDownLatch(1) + val sourceOfTruth = + sourceOfTruth(harness) { key, value -> + harness.upsertRow(key.ns, key.id, value) + if (key == KEY_A) { + holdingWriteEntered.countDown() + assertTrue( + releaseHoldingWrite.await(TIMEOUT_SECONDS, TimeUnit.SECONDS), + ) + } + } + + val holdingWrite = async(Dispatchers.Default) { sourceOfTruth.write(KEY_A, "alpha") } + try { + assertTrue(holdingWriteEntered.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) + AlternatingDispatcher().use { dispatcher -> + val nestedTransaction = + async(dispatcher) { + sourceOfTruth.withTransaction { + sourceOfTruth.write(KEY_B, "bravo") + } + } + + assertTrue( + dispatcher.firstDispatchReturned.await( + TIMEOUT_SECONDS, + TimeUnit.SECONDS, + ), + ) + releaseHoldingWrite.countDown() + holdingWrite.await() + withContext(Dispatchers.Default) { + nestedTransaction.await() + } + } + } finally { + releaseHoldingWrite.countDown() + } + + assertEquals("alpha", harness.selectRow(KEY_A.ns, KEY_A.id).executeAsOneOrNull()) + assertEquals("bravo", harness.selectRow(KEY_B.ns, KEY_B.id).executeAsOneOrNull()) + } + } + + private fun sourceOfTruth( + harness: SqlHarness, + readQuery: (SqlTestKey) -> Query = { key -> + harness.selectRow(key.ns, key.id) + }, + writeRow: (SqlTestKey, String) -> Unit, + ): SqlDelightSourceOfTruth = + SqlDelightSourceOfTruth( + driver = harness.driver, + transacter = harness.transacter, + readQuery = readQuery, + writeRow = writeRow, + deleteRow = { key -> harness.deleteRow(key.ns, key.id) }, + deleteNamespaceRows = { namespace -> harness.deleteNamespace(namespace.value) }, + deleteAllRows = harness::deleteAll, + ) + + private suspend fun withHarness(block: suspend (SqlHarness) -> R): R { + val harness = freshHarness() + return try { + block(harness) + } finally { + harness.driver.close() + } + } + + private class ProbedQuery( + private val delegate: Query, + private val executed: CountDownLatch, + ) : Query({ cursor -> cursor.getString(0)!! }) { + override fun execute(mapper: (SqlCursor) -> QueryResult): QueryResult { + executed.countDown() + return delegate.execute(mapper) + } + + override fun addListener(listener: Listener) { + delegate.addListener(listener) + } + + override fun removeListener(listener: Listener) { + delegate.removeListener(listener) + } + } + + private class AlternatingDispatcher : CoroutineDispatcher(), AutoCloseable { + val firstDispatchReturned = CountDownLatch(1) + private val dispatchIndex = AtomicInteger() + private val firstExecutor = + Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "store6-sqldelight-gate-waiter") + } + private val secondExecutor = + Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "store6-sqldelight-gate-owner") + } + + override fun dispatch(context: CoroutineContext, block: Runnable) { + val index = dispatchIndex.getAndIncrement() + val executor = if (index % 2 == 0) firstExecutor else secondExecutor + executor.execute { + try { + block.run() + } finally { + if (index == 0) firstDispatchReturned.countDown() + } + } + } + + override fun close() { + firstExecutor.shutdownNow() + secondExecutor.shutdownNow() + } + } + + private companion object { + val KEY_A = SqlTestKey(ns = "users", id = "a") + val KEY_B = SqlTestKey(ns = "users", id = "b") + const val TIMEOUT_SECONDS = 5L + const val OVERLAP_PROBE_MILLIS = 250L + const val SUSPENDING_BLOCK_MESSAGE = + "withTransaction block suspended while executing against a synchronous SQLDelight " + + "driver for this store. Keep the block to synchronous same-database statements " + + "(this adapter's write/delete and other SQLDelight statements); move asynchronous " + + "work outside withTransaction." + } +} + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = 25.seconds, testBody = testBody) diff --git a/store6-sqldelight/src/jvmTest/kotlin/org/mobilenativefoundation/store6/sqldelight/FreshHarness.jvm.kt b/store6-sqldelight/src/jvmTest/kotlin/org/mobilenativefoundation/store6/sqldelight/FreshHarness.jvm.kt new file mode 100644 index 000000000..863098909 --- /dev/null +++ b/store6-sqldelight/src/jvmTest/kotlin/org/mobilenativefoundation/store6/sqldelight/FreshHarness.jvm.kt @@ -0,0 +1,9 @@ +package org.mobilenativefoundation.store6.sqldelight + +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver + +internal actual fun freshHarness(): SqlHarness { + val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + TestSchema.create(driver).value + return SqlHarness(driver) +} diff --git a/store6-sqldelight/src/linuxMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/ExecutionThread.linux.kt b/store6-sqldelight/src/linuxMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/ExecutionThread.linux.kt new file mode 100644 index 000000000..0634119ff --- /dev/null +++ b/store6-sqldelight/src/linuxMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/ExecutionThread.linux.kt @@ -0,0 +1,8 @@ +@file:OptIn(ObsoleteWorkersApi::class) + +package org.mobilenativefoundation.store6.sqldelight.internal + +import kotlin.native.concurrent.ObsoleteWorkersApi +import kotlin.native.concurrent.Worker + +internal actual fun currentExecutionThreadId(): Long = Worker.current.id.toLong() diff --git a/store6-sqldelight/src/linuxMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SystemEpochMillis.linux.kt b/store6-sqldelight/src/linuxMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SystemEpochMillis.linux.kt new file mode 100644 index 000000000..507b86083 --- /dev/null +++ b/store6-sqldelight/src/linuxMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SystemEpochMillis.linux.kt @@ -0,0 +1,18 @@ +@file:OptIn(ExperimentalForeignApi::class) + +package org.mobilenativefoundation.store6.sqldelight.internal + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import platform.posix.gettimeofday +import platform.posix.timeval + +/** Returns the Linux system wall clock in Unix epoch milliseconds. */ +internal actual fun currentEpochMillis(): Long = + memScoped { + val now = alloc() + gettimeofday(now.ptr, null) + (now.tv_sec.toLong() * 1_000L) + (now.tv_usec.toLong() / 1_000L) + } diff --git a/store6-sqldelight/src/mingwMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/ExecutionThread.mingw.kt b/store6-sqldelight/src/mingwMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/ExecutionThread.mingw.kt new file mode 100644 index 000000000..0634119ff --- /dev/null +++ b/store6-sqldelight/src/mingwMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/ExecutionThread.mingw.kt @@ -0,0 +1,8 @@ +@file:OptIn(ObsoleteWorkersApi::class) + +package org.mobilenativefoundation.store6.sqldelight.internal + +import kotlin.native.concurrent.ObsoleteWorkersApi +import kotlin.native.concurrent.Worker + +internal actual fun currentExecutionThreadId(): Long = Worker.current.id.toLong() diff --git a/store6-sqldelight/src/mingwMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SystemEpochMillis.mingw.kt b/store6-sqldelight/src/mingwMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SystemEpochMillis.mingw.kt new file mode 100644 index 000000000..883c62186 --- /dev/null +++ b/store6-sqldelight/src/mingwMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SystemEpochMillis.mingw.kt @@ -0,0 +1,21 @@ +@file:OptIn(ExperimentalForeignApi::class) + +package org.mobilenativefoundation.store6.sqldelight.internal + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import platform.windows.FILETIME +import platform.windows.GetSystemTimeAsFileTime + +/** Returns the Windows system wall clock in Unix epoch milliseconds. */ +internal actual fun currentEpochMillis(): Long = + memScoped { + val fileTime = alloc() + GetSystemTimeAsFileTime(fileTime.ptr) + val ticks = + (fileTime.dwHighDateTime.toLong() shl 32) or + fileTime.dwLowDateTime.toLong() + (ticks / 10_000L) - 11_644_473_600_000L + } diff --git a/store6-sqldelight/src/nativeSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/FreshHarness.native.kt b/store6-sqldelight/src/nativeSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/FreshHarness.native.kt new file mode 100644 index 000000000..2aa175796 --- /dev/null +++ b/store6-sqldelight/src/nativeSqlTest/kotlin/org/mobilenativefoundation/store6/sqldelight/FreshHarness.native.kt @@ -0,0 +1,5 @@ +package org.mobilenativefoundation.store6.sqldelight + +import app.cash.sqldelight.driver.native.inMemoryDriver + +internal actual fun freshHarness(): SqlHarness = SqlHarness(inMemoryDriver(TestSchema)) diff --git a/store6-sqldelight/src/wasmJsMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/ExecutionThread.wasmJs.kt b/store6-sqldelight/src/wasmJsMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/ExecutionThread.wasmJs.kt new file mode 100644 index 000000000..db0d391c9 --- /dev/null +++ b/store6-sqldelight/src/wasmJsMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/ExecutionThread.wasmJs.kt @@ -0,0 +1,4 @@ +package org.mobilenativefoundation.store6.sqldelight.internal + +// This adapter has no Wasm-JS driver lane, and the current Wasm-JS runtime is single-threaded. +internal actual fun currentExecutionThreadId(): Long = 0L diff --git a/store6-sqldelight/src/wasmJsMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SystemEpochMillis.wasmJs.kt b/store6-sqldelight/src/wasmJsMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SystemEpochMillis.wasmJs.kt new file mode 100644 index 000000000..914dc4145 --- /dev/null +++ b/store6-sqldelight/src/wasmJsMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/SystemEpochMillis.wasmJs.kt @@ -0,0 +1,6 @@ +package org.mobilenativefoundation.store6.sqldelight.internal + +private fun jsDateNow(): Double = js("Date.now()") + +/** Returns the Wasm-JS system wall clock in Unix epoch milliseconds. */ +internal actual fun currentEpochMillis(): Long = jsDateNow().toLong() diff --git a/store6-swift-dumps/mutations-objc/build.gradle.kts b/store6-swift-dumps/mutations-objc/build.gradle.kts new file mode 100644 index 000000000..b651f8b82 --- /dev/null +++ b/store6-swift-dumps/mutations-objc/build.gradle.kts @@ -0,0 +1,36 @@ +// DRAFT — new file at store6-swift-dumps/mutations-objc/build.gradle.kts +// Modeled on store6-swift-dumps/objc/build.gradle.kts; deltas are the store6SwiftDump block, +// the framework identity, the explicit transitiveExport = false, and the mutations dependency. + +plugins { + id("org.jetbrains.kotlin.multiplatform") + id("org.mobilenativefoundation.store.store6.swift-dump.objc") +} + +store6SwiftDump { + surfaceName.set("store6-mutations") + frameworkName.set("Store6Mutations") + outputHeaderName.set("Store6Mutations.h") + committedDumpPath.set("store6-mutations/api/swift/objc") +} + +kotlin { + iosArm64 { + binaries.framework { + baseName = "Store6Mutations" + // R-2b=A: mutations is the sole direct export root. Core symbols surface only as + // referenced classifiers through the api(:store6-mutations) dependency; exporting + // :store6-core here would broaden the reviewed root-module surface. + transitiveExport = false + export(project(":store6-mutations")) + } + } + + sourceSets { + commonMain { + dependencies { + api(project(":store6-mutations")) + } + } + } +} diff --git a/store6-swift-dumps/mutations-objc/src/commonMain/kotlin/org/mobilenativefoundation/store/store6/swiftdump/mutationsobjc/SwiftDumpAnchor.kt b/store6-swift-dumps/mutations-objc/src/commonMain/kotlin/org/mobilenativefoundation/store/store6/swiftdump/mutationsobjc/SwiftDumpAnchor.kt new file mode 100644 index 000000000..bb2b69a83 --- /dev/null +++ b/store6-swift-dumps/mutations-objc/src/commonMain/kotlin/org/mobilenativefoundation/store/store6/swiftdump/mutationsobjc/SwiftDumpAnchor.kt @@ -0,0 +1,3 @@ +package org.mobilenativefoundation.store.store6.swiftdump.mutationsobjc + +private object SwiftDumpAnchor diff --git a/store6-swift-dumps/mutations-skie/build.gradle.kts b/store6-swift-dumps/mutations-skie/build.gradle.kts new file mode 100644 index 000000000..23e61ecc8 --- /dev/null +++ b/store6-swift-dumps/mutations-skie/build.gradle.kts @@ -0,0 +1,38 @@ +// DRAFT — new file at store6-swift-dumps/mutations-skie/build.gradle.kts +// Modeled on store6-swift-dumps/skie/build.gradle.kts; deltas are the store6SwiftDump block, +// the framework identity, the explicit transitiveExport = false, and the mutations dependency. + +plugins { + id("org.jetbrains.kotlin.multiplatform") + alias(libs.plugins.skie) + id("org.mobilenativefoundation.store.store6.swift-dump.skie") +} + +store6SwiftDump { + surfaceName.set("store6-mutations") + frameworkName.set("Store6MutationsSkie") + outputHeaderName.set("Store6MutationsSkie.h") + outputSwiftName.set("Store6MutationsSkie.swift") + committedDumpPath.set("store6-mutations/api/swift/skie") +} + +kotlin { + iosArm64 { + binaries.framework { + baseName = "Store6MutationsSkie" + // R-2b=A: mutations is the sole direct export root. Core symbols surface only as + // referenced classifiers through the api(:store6-mutations) dependency; exporting + // :store6-core here would broaden the reviewed root-module surface. + transitiveExport = false + export(project(":store6-mutations")) + } + } + + sourceSets { + commonMain { + dependencies { + api(project(":store6-mutations")) + } + } + } +} diff --git a/store6-swift-dumps/mutations-skie/src/commonMain/kotlin/org/mobilenativefoundation/store/store6/swiftdump/mutationsskie/SwiftDumpAnchor.kt b/store6-swift-dumps/mutations-skie/src/commonMain/kotlin/org/mobilenativefoundation/store/store6/swiftdump/mutationsskie/SwiftDumpAnchor.kt new file mode 100644 index 000000000..a43fcbf03 --- /dev/null +++ b/store6-swift-dumps/mutations-skie/src/commonMain/kotlin/org/mobilenativefoundation/store/store6/swiftdump/mutationsskie/SwiftDumpAnchor.kt @@ -0,0 +1,3 @@ +package org.mobilenativefoundation.store.store6.swiftdump.mutationsskie + +private object SwiftDumpAnchor diff --git a/store6-swift-dumps/objc/build.gradle.kts b/store6-swift-dumps/objc/build.gradle.kts new file mode 100644 index 000000000..7a8fea09d --- /dev/null +++ b/store6-swift-dumps/objc/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + id("org.jetbrains.kotlin.multiplatform") + id("org.mobilenativefoundation.store.store6.swift-dump.objc") +} + +kotlin { + iosArm64 { + binaries.framework { + baseName = "Store6Core" + export(project(":store6-core")) + } + } + + sourceSets { + commonMain { + dependencies { + api(project(":store6-core")) + } + } + } +} diff --git a/store6-swift-dumps/objc/src/commonMain/kotlin/org/mobilenativefoundation/store/store6/swiftdump/objc/SwiftDumpAnchor.kt b/store6-swift-dumps/objc/src/commonMain/kotlin/org/mobilenativefoundation/store/store6/swiftdump/objc/SwiftDumpAnchor.kt new file mode 100644 index 000000000..06f0a31ab --- /dev/null +++ b/store6-swift-dumps/objc/src/commonMain/kotlin/org/mobilenativefoundation/store/store6/swiftdump/objc/SwiftDumpAnchor.kt @@ -0,0 +1,3 @@ +package org.mobilenativefoundation.store.store6.swiftdump.objc + +private object SwiftDumpAnchor diff --git a/store6-swift-dumps/skie/build.gradle.kts b/store6-swift-dumps/skie/build.gradle.kts new file mode 100644 index 000000000..a448167e6 --- /dev/null +++ b/store6-swift-dumps/skie/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + id("org.jetbrains.kotlin.multiplatform") + alias(libs.plugins.skie) + id("org.mobilenativefoundation.store.store6.swift-dump.skie") +} + +kotlin { + iosArm64 { + binaries.framework { + baseName = "Store6CoreSkie" + export(project(":store6-core")) + } + } + + sourceSets { + commonMain { + dependencies { + api(project(":store6-core")) + } + } + } +} diff --git a/store6-swift-dumps/skie/src/commonMain/kotlin/org/mobilenativefoundation/store/store6/swiftdump/skie/SwiftDumpAnchor.kt b/store6-swift-dumps/skie/src/commonMain/kotlin/org/mobilenativefoundation/store/store6/swiftdump/skie/SwiftDumpAnchor.kt new file mode 100644 index 000000000..280036496 --- /dev/null +++ b/store6-swift-dumps/skie/src/commonMain/kotlin/org/mobilenativefoundation/store/store6/swiftdump/skie/SwiftDumpAnchor.kt @@ -0,0 +1,3 @@ +package org.mobilenativefoundation.store.store6.swiftdump.skie + +private object SwiftDumpAnchor diff --git a/store6-testing/api/android/store6-testing.api b/store6-testing/api/android/store6-testing.api new file mode 100644 index 000000000..6b6275598 --- /dev/null +++ b/store6-testing/api/android/store6-testing.api @@ -0,0 +1,192 @@ +public abstract class org/mobilenativefoundation/store6/testing/BookkeeperContractKit { + public fun ()V + public abstract fun createBookkeeper ()Lorg/mobilenativefoundation/store6/core/seam/Bookkeeper; + public final fun failureOnlyRecord_isNotDurablyStale ()V + public final fun identityDerivation_differentCanonicalId_isolated ()V + public final fun identityDerivation_equalCanonicalPair_sharesRecords ()V + public final fun namespaceWatermark_thenLaterSuccess_clearsDurableStaleness ()V + public final fun recordSuccess_clearsFailureCountAndTimestamp ()V + public final fun watermarkOnlyKey_reportsDurablyStale ()V +} + +public final class org/mobilenativefoundation/store6/testing/FakeBookkeeper : org/mobilenativefoundation/store6/core/seam/Bookkeeper { + public fun ()V + public fun advanceGlobalStaleWatermark (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun advanceStaleWatermark (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun forget (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun forgetAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun forgetNamespace (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun markStale (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun recordFailure (Lorg/mobilenativefoundation/store6/core/StoreKey;JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun recordSuccess (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/StoreMeta;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun status (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class org/mobilenativefoundation/store6/testing/FakeFetcher : org/mobilenativefoundation/store6/core/seam/Fetcher { + public fun ()V + public final fun clearInvocations ()V + public final fun enqueue (Lorg/mobilenativefoundation/store6/core/StoreKey;[Lorg/mobilenativefoundation/store6/core/seam/FetcherResult;)V + public fun fetch (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun getInvocations ()Ljava/util/List; + public final fun getOnUnscripted ()Lkotlin/jvm/functions/Function2; + public final fun setOnUnscripted (Lkotlin/jvm/functions/Function2;)V +} + +public final class org/mobilenativefoundation/store6/testing/FakeFetcherInvocation { + public fun (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/String;)V + public final fun getEtag ()Ljava/lang/String; + public final fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; +} + +public final class org/mobilenativefoundation/store6/testing/FakeSourceOfTruth : org/mobilenativefoundation/store6/core/seam/SourceOfTruth { + public fun ()V + public fun delete (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteNamespace (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun reader (Lorg/mobilenativefoundation/store6/core/StoreKey;)Lkotlinx/coroutines/flow/Flow; + public fun write (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class org/mobilenativefoundation/store6/testing/FakeStore : org/mobilenativefoundation/store6/core/Store { + public fun ()V + public fun (Lorg/mobilenativefoundation/store6/testing/TestWallClock;)V + public synthetic fun (Lorg/mobilenativefoundation/store6/testing/TestWallClock;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun clear (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun clearAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun clearInteractions ()V + public fun clearNamespace (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun close ()V + public final fun enqueueFetchError (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/StoreError;Z)V + public static synthetic fun enqueueFetchError$default (Lorg/mobilenativefoundation/store6/testing/FakeStore;Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/StoreError;ZILjava/lang/Object;)V + public final fun enqueueFetchRevalidated-HG0u8IE (Lorg/mobilenativefoundation/store6/core/StoreKey;J)V + public static synthetic fun enqueueFetchRevalidated-HG0u8IE$default (Lorg/mobilenativefoundation/store6/testing/FakeStore;Lorg/mobilenativefoundation/store6/core/StoreKey;JILjava/lang/Object;)V + public final fun enqueueFetchValue (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/Object;)V + public fun get (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Freshness;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun getInteractions ()Ljava/util/List; + public final fun getWallClock ()Lorg/mobilenativefoundation/store6/testing/TestWallClock; + public fun invalidate (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun invalidateAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun invalidateNamespace (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun setValue (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/Object;Lorg/mobilenativefoundation/store6/core/Origin;Z)V + public static synthetic fun setValue$default (Lorg/mobilenativefoundation/store6/testing/FakeStore;Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/Object;Lorg/mobilenativefoundation/store6/core/Origin;ZILjava/lang/Object;)V + public fun stream (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Freshness;)Lkotlinx/coroutines/flow/Flow; +} + +public abstract class org/mobilenativefoundation/store6/testing/FakeStoreInteraction { +} + +public final class org/mobilenativefoundation/store6/testing/FakeStoreInteraction$Clear : org/mobilenativefoundation/store6/testing/FakeStoreInteraction { + public fun (Lorg/mobilenativefoundation/store6/core/StoreKey;)V + public final fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; +} + +public final class org/mobilenativefoundation/store6/testing/FakeStoreInteraction$ClearAll : org/mobilenativefoundation/store6/testing/FakeStoreInteraction { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/testing/FakeStoreInteraction$ClearAll; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/testing/FakeStoreInteraction$ClearNamespace : org/mobilenativefoundation/store6/testing/FakeStoreInteraction { + public fun (Lorg/mobilenativefoundation/store6/core/StoreNamespace;)V + public final fun getNamespace ()Lorg/mobilenativefoundation/store6/core/StoreNamespace; +} + +public final class org/mobilenativefoundation/store6/testing/FakeStoreInteraction$Close : org/mobilenativefoundation/store6/testing/FakeStoreInteraction { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/testing/FakeStoreInteraction$Close; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/testing/FakeStoreInteraction$Get : org/mobilenativefoundation/store6/testing/FakeStoreInteraction { + public fun (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Freshness;)V + public final fun getFreshness ()Lorg/mobilenativefoundation/store6/core/Freshness; + public final fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; +} + +public final class org/mobilenativefoundation/store6/testing/FakeStoreInteraction$Invalidate : org/mobilenativefoundation/store6/testing/FakeStoreInteraction { + public fun (Lorg/mobilenativefoundation/store6/core/StoreKey;)V + public final fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; +} + +public final class org/mobilenativefoundation/store6/testing/FakeStoreInteraction$InvalidateAll : org/mobilenativefoundation/store6/testing/FakeStoreInteraction { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/testing/FakeStoreInteraction$InvalidateAll; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/testing/FakeStoreInteraction$InvalidateNamespace : org/mobilenativefoundation/store6/testing/FakeStoreInteraction { + public fun (Lorg/mobilenativefoundation/store6/core/StoreNamespace;)V + public final fun getNamespace ()Lorg/mobilenativefoundation/store6/core/StoreNamespace; +} + +public final class org/mobilenativefoundation/store6/testing/FakeStoreInteraction$Stream : org/mobilenativefoundation/store6/testing/FakeStoreInteraction { + public fun (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Freshness;)V + public final fun getFreshness ()Lorg/mobilenativefoundation/store6/core/Freshness; + public final fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; +} + +public abstract class org/mobilenativefoundation/store6/testing/SourceOfTruthContractKit { + public fun ()V + public abstract fun createSourceOfTruth ()Lorg/mobilenativefoundation/store6/core/seam/SourceOfTruth; + public final fun deleteAllDeletesEveryNamespaceAndEmitsNull ()V + public final fun deleteEmitsNull ()V + public final fun deleteIsVisibleToLateReader ()V + public final fun deleteNamespaceDeletesOnlyMatchingNamespace ()V + public final fun deleteNamespaceEmitsNullToActiveMatchingReader ()V + public final fun equalValueRewriteEmits ()V + public abstract fun getKeyA ()Lorg/mobilenativefoundation/store6/core/StoreKey; + public abstract fun getKeyB ()Lorg/mobilenativefoundation/store6/core/StoreKey; + public abstract fun getKeyOtherNamespace ()Lorg/mobilenativefoundation/store6/core/StoreKey; + public final fun keysAreIsolated ()V + public final fun readerFirstEmissionIsCurrentValue ()V + public final fun readerFirstEmissionIsNullWhenAbsent ()V + public final fun readerNeverCompletesNormally ()V + public final fun readerStaysLiveAcrossDelete ()V + public final fun readerStaysLiveAcrossNamespaceDelete ()V + public final fun readerStaysLiveAcrossThreeDeleteCycles ()V + public final fun twoConcurrentReadersBothSeeWrite ()V + public abstract fun value (I)Ljava/lang/Object; + public final fun writeIsVisibleToLateReader ()V +} + +public final class org/mobilenativefoundation/store6/testing/TestStoreMeta : org/mobilenativefoundation/store6/core/StoreMeta { + public fun (JLjava/lang/String;)V + public synthetic fun (JLjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun getEtag ()Ljava/lang/String; + public fun getWrittenAtEpochMillis ()J +} + +public final class org/mobilenativefoundation/store6/testing/TestStoreResults { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/testing/TestStoreResults; + public final fun conflict (Lorg/mobilenativefoundation/store6/core/StoreMeta;Ljava/lang/String;)Lorg/mobilenativefoundation/store6/core/StoreError$Conflict; + public final fun conversionError (Ljava/lang/String;Ljava/lang/Throwable;)Lorg/mobilenativefoundation/store6/core/StoreError$Conversion; + public static synthetic fun conversionError$default (Lorg/mobilenativefoundation/store6/testing/TestStoreResults;Ljava/lang/String;Ljava/lang/Throwable;ILjava/lang/Object;)Lorg/mobilenativefoundation/store6/core/StoreError$Conversion; + public final fun data-1Y68eR8 (Ljava/lang/Object;Lorg/mobilenativefoundation/store6/core/Origin;JZZ)Lorg/mobilenativefoundation/store6/core/StoreResult$Data; + public static synthetic fun data-1Y68eR8$default (Lorg/mobilenativefoundation/store6/testing/TestStoreResults;Ljava/lang/Object;Lorg/mobilenativefoundation/store6/core/Origin;JZZILjava/lang/Object;)Lorg/mobilenativefoundation/store6/core/StoreResult$Data; + public final fun error (Lorg/mobilenativefoundation/store6/core/StoreError;Z)Lorg/mobilenativefoundation/store6/core/StoreResult$Error; + public static synthetic fun error$default (Lorg/mobilenativefoundation/store6/testing/TestStoreResults;Lorg/mobilenativefoundation/store6/core/StoreError;ZILjava/lang/Object;)Lorg/mobilenativefoundation/store6/core/StoreResult$Error; + public final fun exception (Lorg/mobilenativefoundation/store6/core/StoreError;Ljava/lang/Throwable;)Lorg/mobilenativefoundation/store6/core/StoreException; + public static synthetic fun exception$default (Lorg/mobilenativefoundation/store6/testing/TestStoreResults;Lorg/mobilenativefoundation/store6/core/StoreError;Ljava/lang/Throwable;ILjava/lang/Object;)Lorg/mobilenativefoundation/store6/core/StoreException; + public final fun fetchError (Ljava/lang/String;Ljava/lang/Throwable;)Lorg/mobilenativefoundation/store6/core/StoreError$Fetch; + public static synthetic fun fetchError$default (Lorg/mobilenativefoundation/store6/testing/TestStoreResults;Ljava/lang/String;Ljava/lang/Throwable;ILjava/lang/Object;)Lorg/mobilenativefoundation/store6/core/StoreError$Fetch; + public final fun freshnessUnsatisfiable (Ljava/lang/String;)Lorg/mobilenativefoundation/store6/core/StoreError$FreshnessUnsatisfiable; + public final fun loading ()Lorg/mobilenativefoundation/store6/core/StoreResult$Loading; + public final fun missing (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/String;)Lorg/mobilenativefoundation/store6/core/StoreError$Missing; + public final fun persistenceError (Ljava/lang/String;Ljava/lang/Throwable;)Lorg/mobilenativefoundation/store6/core/StoreError$Persistence; + public static synthetic fun persistenceError$default (Lorg/mobilenativefoundation/store6/testing/TestStoreResults;Ljava/lang/String;Ljava/lang/Throwable;ILjava/lang/Object;)Lorg/mobilenativefoundation/store6/core/StoreError$Persistence; + public final fun revalidated-LRDsOJo (J)Lorg/mobilenativefoundation/store6/core/StoreResult$Revalidated; + public static synthetic fun revalidated-LRDsOJo$default (Lorg/mobilenativefoundation/store6/testing/TestStoreResults;JILjava/lang/Object;)Lorg/mobilenativefoundation/store6/core/StoreResult$Revalidated; +} + +public final class org/mobilenativefoundation/store6/testing/TestWallClock : org/mobilenativefoundation/store6/core/seam/WallClock { + public fun ()V + public fun (J)V + public synthetic fun (JILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun advanceBy-LRDsOJo (J)V + public fun nowEpochMillis ()J + public final fun setEpochMillis (J)V +} + diff --git a/store6-testing/api/jvm/store6-testing.api b/store6-testing/api/jvm/store6-testing.api new file mode 100644 index 000000000..6b6275598 --- /dev/null +++ b/store6-testing/api/jvm/store6-testing.api @@ -0,0 +1,192 @@ +public abstract class org/mobilenativefoundation/store6/testing/BookkeeperContractKit { + public fun ()V + public abstract fun createBookkeeper ()Lorg/mobilenativefoundation/store6/core/seam/Bookkeeper; + public final fun failureOnlyRecord_isNotDurablyStale ()V + public final fun identityDerivation_differentCanonicalId_isolated ()V + public final fun identityDerivation_equalCanonicalPair_sharesRecords ()V + public final fun namespaceWatermark_thenLaterSuccess_clearsDurableStaleness ()V + public final fun recordSuccess_clearsFailureCountAndTimestamp ()V + public final fun watermarkOnlyKey_reportsDurablyStale ()V +} + +public final class org/mobilenativefoundation/store6/testing/FakeBookkeeper : org/mobilenativefoundation/store6/core/seam/Bookkeeper { + public fun ()V + public fun advanceGlobalStaleWatermark (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun advanceStaleWatermark (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun forget (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun forgetAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun forgetNamespace (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun markStale (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun recordFailure (Lorg/mobilenativefoundation/store6/core/StoreKey;JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun recordSuccess (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/StoreMeta;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun status (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class org/mobilenativefoundation/store6/testing/FakeFetcher : org/mobilenativefoundation/store6/core/seam/Fetcher { + public fun ()V + public final fun clearInvocations ()V + public final fun enqueue (Lorg/mobilenativefoundation/store6/core/StoreKey;[Lorg/mobilenativefoundation/store6/core/seam/FetcherResult;)V + public fun fetch (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun getInvocations ()Ljava/util/List; + public final fun getOnUnscripted ()Lkotlin/jvm/functions/Function2; + public final fun setOnUnscripted (Lkotlin/jvm/functions/Function2;)V +} + +public final class org/mobilenativefoundation/store6/testing/FakeFetcherInvocation { + public fun (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/String;)V + public final fun getEtag ()Ljava/lang/String; + public final fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; +} + +public final class org/mobilenativefoundation/store6/testing/FakeSourceOfTruth : org/mobilenativefoundation/store6/core/seam/SourceOfTruth { + public fun ()V + public fun delete (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteNamespace (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun reader (Lorg/mobilenativefoundation/store6/core/StoreKey;)Lkotlinx/coroutines/flow/Flow; + public fun write (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class org/mobilenativefoundation/store6/testing/FakeStore : org/mobilenativefoundation/store6/core/Store { + public fun ()V + public fun (Lorg/mobilenativefoundation/store6/testing/TestWallClock;)V + public synthetic fun (Lorg/mobilenativefoundation/store6/testing/TestWallClock;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun clear (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun clearAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun clearInteractions ()V + public fun clearNamespace (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun close ()V + public final fun enqueueFetchError (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/StoreError;Z)V + public static synthetic fun enqueueFetchError$default (Lorg/mobilenativefoundation/store6/testing/FakeStore;Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/StoreError;ZILjava/lang/Object;)V + public final fun enqueueFetchRevalidated-HG0u8IE (Lorg/mobilenativefoundation/store6/core/StoreKey;J)V + public static synthetic fun enqueueFetchRevalidated-HG0u8IE$default (Lorg/mobilenativefoundation/store6/testing/FakeStore;Lorg/mobilenativefoundation/store6/core/StoreKey;JILjava/lang/Object;)V + public final fun enqueueFetchValue (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/Object;)V + public fun get (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Freshness;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun getInteractions ()Ljava/util/List; + public final fun getWallClock ()Lorg/mobilenativefoundation/store6/testing/TestWallClock; + public fun invalidate (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun invalidateAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun invalidateNamespace (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun setValue (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/Object;Lorg/mobilenativefoundation/store6/core/Origin;Z)V + public static synthetic fun setValue$default (Lorg/mobilenativefoundation/store6/testing/FakeStore;Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/Object;Lorg/mobilenativefoundation/store6/core/Origin;ZILjava/lang/Object;)V + public fun stream (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Freshness;)Lkotlinx/coroutines/flow/Flow; +} + +public abstract class org/mobilenativefoundation/store6/testing/FakeStoreInteraction { +} + +public final class org/mobilenativefoundation/store6/testing/FakeStoreInteraction$Clear : org/mobilenativefoundation/store6/testing/FakeStoreInteraction { + public fun (Lorg/mobilenativefoundation/store6/core/StoreKey;)V + public final fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; +} + +public final class org/mobilenativefoundation/store6/testing/FakeStoreInteraction$ClearAll : org/mobilenativefoundation/store6/testing/FakeStoreInteraction { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/testing/FakeStoreInteraction$ClearAll; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/testing/FakeStoreInteraction$ClearNamespace : org/mobilenativefoundation/store6/testing/FakeStoreInteraction { + public fun (Lorg/mobilenativefoundation/store6/core/StoreNamespace;)V + public final fun getNamespace ()Lorg/mobilenativefoundation/store6/core/StoreNamespace; +} + +public final class org/mobilenativefoundation/store6/testing/FakeStoreInteraction$Close : org/mobilenativefoundation/store6/testing/FakeStoreInteraction { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/testing/FakeStoreInteraction$Close; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/testing/FakeStoreInteraction$Get : org/mobilenativefoundation/store6/testing/FakeStoreInteraction { + public fun (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Freshness;)V + public final fun getFreshness ()Lorg/mobilenativefoundation/store6/core/Freshness; + public final fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; +} + +public final class org/mobilenativefoundation/store6/testing/FakeStoreInteraction$Invalidate : org/mobilenativefoundation/store6/testing/FakeStoreInteraction { + public fun (Lorg/mobilenativefoundation/store6/core/StoreKey;)V + public final fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; +} + +public final class org/mobilenativefoundation/store6/testing/FakeStoreInteraction$InvalidateAll : org/mobilenativefoundation/store6/testing/FakeStoreInteraction { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/testing/FakeStoreInteraction$InvalidateAll; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/testing/FakeStoreInteraction$InvalidateNamespace : org/mobilenativefoundation/store6/testing/FakeStoreInteraction { + public fun (Lorg/mobilenativefoundation/store6/core/StoreNamespace;)V + public final fun getNamespace ()Lorg/mobilenativefoundation/store6/core/StoreNamespace; +} + +public final class org/mobilenativefoundation/store6/testing/FakeStoreInteraction$Stream : org/mobilenativefoundation/store6/testing/FakeStoreInteraction { + public fun (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Freshness;)V + public final fun getFreshness ()Lorg/mobilenativefoundation/store6/core/Freshness; + public final fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; +} + +public abstract class org/mobilenativefoundation/store6/testing/SourceOfTruthContractKit { + public fun ()V + public abstract fun createSourceOfTruth ()Lorg/mobilenativefoundation/store6/core/seam/SourceOfTruth; + public final fun deleteAllDeletesEveryNamespaceAndEmitsNull ()V + public final fun deleteEmitsNull ()V + public final fun deleteIsVisibleToLateReader ()V + public final fun deleteNamespaceDeletesOnlyMatchingNamespace ()V + public final fun deleteNamespaceEmitsNullToActiveMatchingReader ()V + public final fun equalValueRewriteEmits ()V + public abstract fun getKeyA ()Lorg/mobilenativefoundation/store6/core/StoreKey; + public abstract fun getKeyB ()Lorg/mobilenativefoundation/store6/core/StoreKey; + public abstract fun getKeyOtherNamespace ()Lorg/mobilenativefoundation/store6/core/StoreKey; + public final fun keysAreIsolated ()V + public final fun readerFirstEmissionIsCurrentValue ()V + public final fun readerFirstEmissionIsNullWhenAbsent ()V + public final fun readerNeverCompletesNormally ()V + public final fun readerStaysLiveAcrossDelete ()V + public final fun readerStaysLiveAcrossNamespaceDelete ()V + public final fun readerStaysLiveAcrossThreeDeleteCycles ()V + public final fun twoConcurrentReadersBothSeeWrite ()V + public abstract fun value (I)Ljava/lang/Object; + public final fun writeIsVisibleToLateReader ()V +} + +public final class org/mobilenativefoundation/store6/testing/TestStoreMeta : org/mobilenativefoundation/store6/core/StoreMeta { + public fun (JLjava/lang/String;)V + public synthetic fun (JLjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun getEtag ()Ljava/lang/String; + public fun getWrittenAtEpochMillis ()J +} + +public final class org/mobilenativefoundation/store6/testing/TestStoreResults { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/testing/TestStoreResults; + public final fun conflict (Lorg/mobilenativefoundation/store6/core/StoreMeta;Ljava/lang/String;)Lorg/mobilenativefoundation/store6/core/StoreError$Conflict; + public final fun conversionError (Ljava/lang/String;Ljava/lang/Throwable;)Lorg/mobilenativefoundation/store6/core/StoreError$Conversion; + public static synthetic fun conversionError$default (Lorg/mobilenativefoundation/store6/testing/TestStoreResults;Ljava/lang/String;Ljava/lang/Throwable;ILjava/lang/Object;)Lorg/mobilenativefoundation/store6/core/StoreError$Conversion; + public final fun data-1Y68eR8 (Ljava/lang/Object;Lorg/mobilenativefoundation/store6/core/Origin;JZZ)Lorg/mobilenativefoundation/store6/core/StoreResult$Data; + public static synthetic fun data-1Y68eR8$default (Lorg/mobilenativefoundation/store6/testing/TestStoreResults;Ljava/lang/Object;Lorg/mobilenativefoundation/store6/core/Origin;JZZILjava/lang/Object;)Lorg/mobilenativefoundation/store6/core/StoreResult$Data; + public final fun error (Lorg/mobilenativefoundation/store6/core/StoreError;Z)Lorg/mobilenativefoundation/store6/core/StoreResult$Error; + public static synthetic fun error$default (Lorg/mobilenativefoundation/store6/testing/TestStoreResults;Lorg/mobilenativefoundation/store6/core/StoreError;ZILjava/lang/Object;)Lorg/mobilenativefoundation/store6/core/StoreResult$Error; + public final fun exception (Lorg/mobilenativefoundation/store6/core/StoreError;Ljava/lang/Throwable;)Lorg/mobilenativefoundation/store6/core/StoreException; + public static synthetic fun exception$default (Lorg/mobilenativefoundation/store6/testing/TestStoreResults;Lorg/mobilenativefoundation/store6/core/StoreError;Ljava/lang/Throwable;ILjava/lang/Object;)Lorg/mobilenativefoundation/store6/core/StoreException; + public final fun fetchError (Ljava/lang/String;Ljava/lang/Throwable;)Lorg/mobilenativefoundation/store6/core/StoreError$Fetch; + public static synthetic fun fetchError$default (Lorg/mobilenativefoundation/store6/testing/TestStoreResults;Ljava/lang/String;Ljava/lang/Throwable;ILjava/lang/Object;)Lorg/mobilenativefoundation/store6/core/StoreError$Fetch; + public final fun freshnessUnsatisfiable (Ljava/lang/String;)Lorg/mobilenativefoundation/store6/core/StoreError$FreshnessUnsatisfiable; + public final fun loading ()Lorg/mobilenativefoundation/store6/core/StoreResult$Loading; + public final fun missing (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/String;)Lorg/mobilenativefoundation/store6/core/StoreError$Missing; + public final fun persistenceError (Ljava/lang/String;Ljava/lang/Throwable;)Lorg/mobilenativefoundation/store6/core/StoreError$Persistence; + public static synthetic fun persistenceError$default (Lorg/mobilenativefoundation/store6/testing/TestStoreResults;Ljava/lang/String;Ljava/lang/Throwable;ILjava/lang/Object;)Lorg/mobilenativefoundation/store6/core/StoreError$Persistence; + public final fun revalidated-LRDsOJo (J)Lorg/mobilenativefoundation/store6/core/StoreResult$Revalidated; + public static synthetic fun revalidated-LRDsOJo$default (Lorg/mobilenativefoundation/store6/testing/TestStoreResults;JILjava/lang/Object;)Lorg/mobilenativefoundation/store6/core/StoreResult$Revalidated; +} + +public final class org/mobilenativefoundation/store6/testing/TestWallClock : org/mobilenativefoundation/store6/core/seam/WallClock { + public fun ()V + public fun (J)V + public synthetic fun (JILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun advanceBy-LRDsOJo (J)V + public fun nowEpochMillis ()J + public final fun setEpochMillis (J)V +} + diff --git a/store6-testing/api/store6-testing.klib.api b/store6-testing/api/store6-testing.klib.api new file mode 100644 index 000000000..85bfc6b48 --- /dev/null +++ b/store6-testing/api/store6-testing.klib.api @@ -0,0 +1,386 @@ +// Klib ABI Dump +// Targets: [iosArm64, iosSimulatorArm64, iosX64, js, linuxX64, macosArm64, mingwX64, tvosArm64, wasmJs, watchosArm64] +// Alias: native => [iosArm64, iosSimulatorArm64, iosX64, linuxX64, macosArm64, mingwX64, tvosArm64, watchosArm64] +// Rendering settings: +// - Signature version: 2 +// - Show manifest properties: true +// - Show declarations: true + +// Library unique name: +abstract class <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit { // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit|null[0] + constructor () // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.|(){}[0] + + abstract val keyA // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.keyA|{}keyA[0] + abstract fun (): #A // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.keyA.|(){}[0] + abstract val keyB // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.keyB|{}keyB[0] + abstract fun (): #A // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.keyB.|(){}[0] + abstract val keyOtherNamespace // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.keyOtherNamespace|{}keyOtherNamespace[0] + abstract fun (): #A // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.keyOtherNamespace.|(){}[0] + + abstract fun createSourceOfTruth(): org.mobilenativefoundation.store6.core.seam/SourceOfTruth<#A, #B> // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.createSourceOfTruth|createSourceOfTruth(){}[0] + abstract fun value(kotlin/Int): #B // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.value|value(kotlin.Int){}[0] + + // Targets: [native] + final fun deleteAllDeletesEveryNamespaceAndEmitsNull() // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.deleteAllDeletesEveryNamespaceAndEmitsNull|deleteAllDeletesEveryNamespaceAndEmitsNull(){}[0] + + // Targets: [native] + final fun deleteEmitsNull() // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.deleteEmitsNull|deleteEmitsNull(){}[0] + + // Targets: [native] + final fun deleteIsVisibleToLateReader() // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.deleteIsVisibleToLateReader|deleteIsVisibleToLateReader(){}[0] + + // Targets: [native] + final fun deleteNamespaceDeletesOnlyMatchingNamespace() // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.deleteNamespaceDeletesOnlyMatchingNamespace|deleteNamespaceDeletesOnlyMatchingNamespace(){}[0] + + // Targets: [native] + final fun deleteNamespaceEmitsNullToActiveMatchingReader() // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.deleteNamespaceEmitsNullToActiveMatchingReader|deleteNamespaceEmitsNullToActiveMatchingReader(){}[0] + + // Targets: [native] + final fun equalValueRewriteEmits() // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.equalValueRewriteEmits|equalValueRewriteEmits(){}[0] + + // Targets: [native] + final fun keysAreIsolated() // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.keysAreIsolated|keysAreIsolated(){}[0] + + // Targets: [native] + final fun readerFirstEmissionIsCurrentValue() // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.readerFirstEmissionIsCurrentValue|readerFirstEmissionIsCurrentValue(){}[0] + + // Targets: [native] + final fun readerFirstEmissionIsNullWhenAbsent() // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.readerFirstEmissionIsNullWhenAbsent|readerFirstEmissionIsNullWhenAbsent(){}[0] + + // Targets: [native] + final fun readerNeverCompletesNormally() // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.readerNeverCompletesNormally|readerNeverCompletesNormally(){}[0] + + // Targets: [native] + final fun readerStaysLiveAcrossDelete() // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.readerStaysLiveAcrossDelete|readerStaysLiveAcrossDelete(){}[0] + + // Targets: [native] + final fun readerStaysLiveAcrossNamespaceDelete() // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.readerStaysLiveAcrossNamespaceDelete|readerStaysLiveAcrossNamespaceDelete(){}[0] + + // Targets: [native] + final fun readerStaysLiveAcrossThreeDeleteCycles() // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.readerStaysLiveAcrossThreeDeleteCycles|readerStaysLiveAcrossThreeDeleteCycles(){}[0] + + // Targets: [native] + final fun twoConcurrentReadersBothSeeWrite() // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.twoConcurrentReadersBothSeeWrite|twoConcurrentReadersBothSeeWrite(){}[0] + + // Targets: [native] + final fun writeIsVisibleToLateReader() // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.writeIsVisibleToLateReader|writeIsVisibleToLateReader(){}[0] + + // Targets: [js] + final fun deleteAllDeletesEveryNamespaceAndEmitsNull(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.deleteAllDeletesEveryNamespaceAndEmitsNull|deleteAllDeletesEveryNamespaceAndEmitsNull(){}[0] + + // Targets: [js] + final fun deleteEmitsNull(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.deleteEmitsNull|deleteEmitsNull(){}[0] + + // Targets: [js] + final fun deleteIsVisibleToLateReader(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.deleteIsVisibleToLateReader|deleteIsVisibleToLateReader(){}[0] + + // Targets: [js] + final fun deleteNamespaceDeletesOnlyMatchingNamespace(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.deleteNamespaceDeletesOnlyMatchingNamespace|deleteNamespaceDeletesOnlyMatchingNamespace(){}[0] + + // Targets: [js] + final fun deleteNamespaceEmitsNullToActiveMatchingReader(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.deleteNamespaceEmitsNullToActiveMatchingReader|deleteNamespaceEmitsNullToActiveMatchingReader(){}[0] + + // Targets: [js] + final fun equalValueRewriteEmits(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.equalValueRewriteEmits|equalValueRewriteEmits(){}[0] + + // Targets: [js] + final fun keysAreIsolated(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.keysAreIsolated|keysAreIsolated(){}[0] + + // Targets: [js] + final fun readerFirstEmissionIsCurrentValue(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.readerFirstEmissionIsCurrentValue|readerFirstEmissionIsCurrentValue(){}[0] + + // Targets: [js] + final fun readerFirstEmissionIsNullWhenAbsent(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.readerFirstEmissionIsNullWhenAbsent|readerFirstEmissionIsNullWhenAbsent(){}[0] + + // Targets: [js] + final fun readerNeverCompletesNormally(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.readerNeverCompletesNormally|readerNeverCompletesNormally(){}[0] + + // Targets: [js] + final fun readerStaysLiveAcrossDelete(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.readerStaysLiveAcrossDelete|readerStaysLiveAcrossDelete(){}[0] + + // Targets: [js] + final fun readerStaysLiveAcrossNamespaceDelete(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.readerStaysLiveAcrossNamespaceDelete|readerStaysLiveAcrossNamespaceDelete(){}[0] + + // Targets: [js] + final fun readerStaysLiveAcrossThreeDeleteCycles(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.readerStaysLiveAcrossThreeDeleteCycles|readerStaysLiveAcrossThreeDeleteCycles(){}[0] + + // Targets: [js] + final fun twoConcurrentReadersBothSeeWrite(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.twoConcurrentReadersBothSeeWrite|twoConcurrentReadersBothSeeWrite(){}[0] + + // Targets: [js] + final fun writeIsVisibleToLateReader(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.writeIsVisibleToLateReader|writeIsVisibleToLateReader(){}[0] + + // Targets: [wasmJs] + final fun deleteAllDeletesEveryNamespaceAndEmitsNull(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.deleteAllDeletesEveryNamespaceAndEmitsNull|deleteAllDeletesEveryNamespaceAndEmitsNull(){}[0] + + // Targets: [wasmJs] + final fun deleteEmitsNull(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.deleteEmitsNull|deleteEmitsNull(){}[0] + + // Targets: [wasmJs] + final fun deleteIsVisibleToLateReader(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.deleteIsVisibleToLateReader|deleteIsVisibleToLateReader(){}[0] + + // Targets: [wasmJs] + final fun deleteNamespaceDeletesOnlyMatchingNamespace(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.deleteNamespaceDeletesOnlyMatchingNamespace|deleteNamespaceDeletesOnlyMatchingNamespace(){}[0] + + // Targets: [wasmJs] + final fun deleteNamespaceEmitsNullToActiveMatchingReader(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.deleteNamespaceEmitsNullToActiveMatchingReader|deleteNamespaceEmitsNullToActiveMatchingReader(){}[0] + + // Targets: [wasmJs] + final fun equalValueRewriteEmits(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.equalValueRewriteEmits|equalValueRewriteEmits(){}[0] + + // Targets: [wasmJs] + final fun keysAreIsolated(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.keysAreIsolated|keysAreIsolated(){}[0] + + // Targets: [wasmJs] + final fun readerFirstEmissionIsCurrentValue(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.readerFirstEmissionIsCurrentValue|readerFirstEmissionIsCurrentValue(){}[0] + + // Targets: [wasmJs] + final fun readerFirstEmissionIsNullWhenAbsent(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.readerFirstEmissionIsNullWhenAbsent|readerFirstEmissionIsNullWhenAbsent(){}[0] + + // Targets: [wasmJs] + final fun readerNeverCompletesNormally(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.readerNeverCompletesNormally|readerNeverCompletesNormally(){}[0] + + // Targets: [wasmJs] + final fun readerStaysLiveAcrossDelete(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.readerStaysLiveAcrossDelete|readerStaysLiveAcrossDelete(){}[0] + + // Targets: [wasmJs] + final fun readerStaysLiveAcrossNamespaceDelete(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.readerStaysLiveAcrossNamespaceDelete|readerStaysLiveAcrossNamespaceDelete(){}[0] + + // Targets: [wasmJs] + final fun readerStaysLiveAcrossThreeDeleteCycles(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.readerStaysLiveAcrossThreeDeleteCycles|readerStaysLiveAcrossThreeDeleteCycles(){}[0] + + // Targets: [wasmJs] + final fun twoConcurrentReadersBothSeeWrite(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.twoConcurrentReadersBothSeeWrite|twoConcurrentReadersBothSeeWrite(){}[0] + + // Targets: [wasmJs] + final fun writeIsVisibleToLateReader(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/SourceOfTruthContractKit.writeIsVisibleToLateReader|writeIsVisibleToLateReader(){}[0] +} + +abstract class org.mobilenativefoundation.store6.testing/BookkeeperContractKit { // org.mobilenativefoundation.store6.testing/BookkeeperContractKit|null[0] + constructor () // org.mobilenativefoundation.store6.testing/BookkeeperContractKit.|(){}[0] + + abstract fun createBookkeeper(): org.mobilenativefoundation.store6.core.seam/Bookkeeper // org.mobilenativefoundation.store6.testing/BookkeeperContractKit.createBookkeeper|createBookkeeper(){}[0] + + // Targets: [native] + final fun failureOnlyRecord_isNotDurablyStale() // org.mobilenativefoundation.store6.testing/BookkeeperContractKit.failureOnlyRecord_isNotDurablyStale|failureOnlyRecord_isNotDurablyStale(){}[0] + + // Targets: [native] + final fun identityDerivation_differentCanonicalId_isolated() // org.mobilenativefoundation.store6.testing/BookkeeperContractKit.identityDerivation_differentCanonicalId_isolated|identityDerivation_differentCanonicalId_isolated(){}[0] + + // Targets: [native] + final fun identityDerivation_equalCanonicalPair_sharesRecords() // org.mobilenativefoundation.store6.testing/BookkeeperContractKit.identityDerivation_equalCanonicalPair_sharesRecords|identityDerivation_equalCanonicalPair_sharesRecords(){}[0] + + // Targets: [native] + final fun namespaceWatermark_thenLaterSuccess_clearsDurableStaleness() // org.mobilenativefoundation.store6.testing/BookkeeperContractKit.namespaceWatermark_thenLaterSuccess_clearsDurableStaleness|namespaceWatermark_thenLaterSuccess_clearsDurableStaleness(){}[0] + + // Targets: [native] + final fun recordSuccess_clearsFailureCountAndTimestamp() // org.mobilenativefoundation.store6.testing/BookkeeperContractKit.recordSuccess_clearsFailureCountAndTimestamp|recordSuccess_clearsFailureCountAndTimestamp(){}[0] + + // Targets: [native] + final fun watermarkOnlyKey_reportsDurablyStale() // org.mobilenativefoundation.store6.testing/BookkeeperContractKit.watermarkOnlyKey_reportsDurablyStale|watermarkOnlyKey_reportsDurablyStale(){}[0] + + // Targets: [js] + final fun failureOnlyRecord_isNotDurablyStale(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/BookkeeperContractKit.failureOnlyRecord_isNotDurablyStale|failureOnlyRecord_isNotDurablyStale(){}[0] + + // Targets: [js] + final fun identityDerivation_differentCanonicalId_isolated(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/BookkeeperContractKit.identityDerivation_differentCanonicalId_isolated|identityDerivation_differentCanonicalId_isolated(){}[0] + + // Targets: [js] + final fun identityDerivation_equalCanonicalPair_sharesRecords(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/BookkeeperContractKit.identityDerivation_equalCanonicalPair_sharesRecords|identityDerivation_equalCanonicalPair_sharesRecords(){}[0] + + // Targets: [js] + final fun namespaceWatermark_thenLaterSuccess_clearsDurableStaleness(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/BookkeeperContractKit.namespaceWatermark_thenLaterSuccess_clearsDurableStaleness|namespaceWatermark_thenLaterSuccess_clearsDurableStaleness(){}[0] + + // Targets: [js] + final fun recordSuccess_clearsFailureCountAndTimestamp(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/BookkeeperContractKit.recordSuccess_clearsFailureCountAndTimestamp|recordSuccess_clearsFailureCountAndTimestamp(){}[0] + + // Targets: [js] + final fun watermarkOnlyKey_reportsDurablyStale(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/BookkeeperContractKit.watermarkOnlyKey_reportsDurablyStale|watermarkOnlyKey_reportsDurablyStale(){}[0] + + // Targets: [wasmJs] + final fun failureOnlyRecord_isNotDurablyStale(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/BookkeeperContractKit.failureOnlyRecord_isNotDurablyStale|failureOnlyRecord_isNotDurablyStale(){}[0] + + // Targets: [wasmJs] + final fun identityDerivation_differentCanonicalId_isolated(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/BookkeeperContractKit.identityDerivation_differentCanonicalId_isolated|identityDerivation_differentCanonicalId_isolated(){}[0] + + // Targets: [wasmJs] + final fun identityDerivation_equalCanonicalPair_sharesRecords(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/BookkeeperContractKit.identityDerivation_equalCanonicalPair_sharesRecords|identityDerivation_equalCanonicalPair_sharesRecords(){}[0] + + // Targets: [wasmJs] + final fun namespaceWatermark_thenLaterSuccess_clearsDurableStaleness(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/BookkeeperContractKit.namespaceWatermark_thenLaterSuccess_clearsDurableStaleness|namespaceWatermark_thenLaterSuccess_clearsDurableStaleness(){}[0] + + // Targets: [wasmJs] + final fun recordSuccess_clearsFailureCountAndTimestamp(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/BookkeeperContractKit.recordSuccess_clearsFailureCountAndTimestamp|recordSuccess_clearsFailureCountAndTimestamp(){}[0] + + // Targets: [wasmJs] + final fun watermarkOnlyKey_reportsDurablyStale(): kotlin.js/Promise // org.mobilenativefoundation.store6.testing/BookkeeperContractKit.watermarkOnlyKey_reportsDurablyStale|watermarkOnlyKey_reportsDurablyStale(){}[0] +} + +final class <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> org.mobilenativefoundation.store6.testing/FakeFetcher : org.mobilenativefoundation.store6.core.seam/Fetcher<#A, #B> { // org.mobilenativefoundation.store6.testing/FakeFetcher|null[0] + constructor () // org.mobilenativefoundation.store6.testing/FakeFetcher.|(){}[0] + + final val invocations // org.mobilenativefoundation.store6.testing/FakeFetcher.invocations|{}invocations[0] + final fun (): kotlin.collections/List // org.mobilenativefoundation.store6.testing/FakeFetcher.invocations.|(){}[0] + + final var onUnscripted // org.mobilenativefoundation.store6.testing/FakeFetcher.onUnscripted|{}onUnscripted[0] + final fun (): kotlin/Function2<#A, kotlin/String?, org.mobilenativefoundation.store6.core.seam/FetcherResult<#B>> // org.mobilenativefoundation.store6.testing/FakeFetcher.onUnscripted.|(){}[0] + final fun (kotlin/Function2<#A, kotlin/String?, org.mobilenativefoundation.store6.core.seam/FetcherResult<#B>>) // org.mobilenativefoundation.store6.testing/FakeFetcher.onUnscripted.|(kotlin.Function2<1:0,kotlin.String?,org.mobilenativefoundation.store6.core.seam.FetcherResult<1:1>>){}[0] + + final fun clearInvocations() // org.mobilenativefoundation.store6.testing/FakeFetcher.clearInvocations|clearInvocations(){}[0] + final fun enqueue(#A, kotlin/Array>...) // org.mobilenativefoundation.store6.testing/FakeFetcher.enqueue|enqueue(1:0;kotlin.Array>...){}[0] + final suspend fun fetch(#A, kotlin/String?): org.mobilenativefoundation.store6.core.seam/FetcherResult<#B> // org.mobilenativefoundation.store6.testing/FakeFetcher.fetch|fetch(1:0;kotlin.String?){}[0] +} + +final class <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> org.mobilenativefoundation.store6.testing/FakeSourceOfTruth : org.mobilenativefoundation.store6.core.seam/SourceOfTruth<#A, #B> { // org.mobilenativefoundation.store6.testing/FakeSourceOfTruth|null[0] + constructor () // org.mobilenativefoundation.store6.testing/FakeSourceOfTruth.|(){}[0] + + final fun reader(#A): kotlinx.coroutines.flow/Flow<#B?> // org.mobilenativefoundation.store6.testing/FakeSourceOfTruth.reader|reader(1:0){}[0] + final suspend fun delete(#A) // org.mobilenativefoundation.store6.testing/FakeSourceOfTruth.delete|delete(1:0){}[0] + final suspend fun deleteAll() // org.mobilenativefoundation.store6.testing/FakeSourceOfTruth.deleteAll|deleteAll(){}[0] + final suspend fun deleteNamespace(org.mobilenativefoundation.store6.core/StoreNamespace) // org.mobilenativefoundation.store6.testing/FakeSourceOfTruth.deleteNamespace|deleteNamespace(org.mobilenativefoundation.store6.core.StoreNamespace){}[0] + final suspend fun write(#A, #B) // org.mobilenativefoundation.store6.testing/FakeSourceOfTruth.write|write(1:0;1:1){}[0] +} + +final class <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> org.mobilenativefoundation.store6.testing/FakeStore : org.mobilenativefoundation.store6.core/Store<#A, #B> { // org.mobilenativefoundation.store6.testing/FakeStore|null[0] + constructor (org.mobilenativefoundation.store6.testing/TestWallClock = ...) // org.mobilenativefoundation.store6.testing/FakeStore.|(org.mobilenativefoundation.store6.testing.TestWallClock){}[0] + + final val interactions // org.mobilenativefoundation.store6.testing/FakeStore.interactions|{}interactions[0] + final fun (): kotlin.collections/List // org.mobilenativefoundation.store6.testing/FakeStore.interactions.|(){}[0] + final val wallClock // org.mobilenativefoundation.store6.testing/FakeStore.wallClock|{}wallClock[0] + final fun (): org.mobilenativefoundation.store6.testing/TestWallClock // org.mobilenativefoundation.store6.testing/FakeStore.wallClock.|(){}[0] + + final fun clearInteractions() // org.mobilenativefoundation.store6.testing/FakeStore.clearInteractions|clearInteractions(){}[0] + final fun close() // org.mobilenativefoundation.store6.testing/FakeStore.close|close(){}[0] + final fun enqueueFetchError(#A, org.mobilenativefoundation.store6.core/StoreError, kotlin/Boolean = ...) // org.mobilenativefoundation.store6.testing/FakeStore.enqueueFetchError|enqueueFetchError(1:0;org.mobilenativefoundation.store6.core.StoreError;kotlin.Boolean){}[0] + final fun enqueueFetchRevalidated(#A, kotlin.time/Duration = ...) // org.mobilenativefoundation.store6.testing/FakeStore.enqueueFetchRevalidated|enqueueFetchRevalidated(1:0;kotlin.time.Duration){}[0] + final fun enqueueFetchValue(#A, #B) // org.mobilenativefoundation.store6.testing/FakeStore.enqueueFetchValue|enqueueFetchValue(1:0;1:1){}[0] + final fun setValue(#A, #B, org.mobilenativefoundation.store6.core/Origin = ..., kotlin/Boolean = ...) // org.mobilenativefoundation.store6.testing/FakeStore.setValue|setValue(1:0;1:1;org.mobilenativefoundation.store6.core.Origin;kotlin.Boolean){}[0] + final fun stream(#A, org.mobilenativefoundation.store6.core/Freshness): kotlinx.coroutines.flow/Flow> // org.mobilenativefoundation.store6.testing/FakeStore.stream|stream(1:0;org.mobilenativefoundation.store6.core.Freshness){}[0] + final suspend fun clear(#A) // org.mobilenativefoundation.store6.testing/FakeStore.clear|clear(1:0){}[0] + final suspend fun clearAll() // org.mobilenativefoundation.store6.testing/FakeStore.clearAll|clearAll(){}[0] + final suspend fun clearNamespace(org.mobilenativefoundation.store6.core/StoreNamespace) // org.mobilenativefoundation.store6.testing/FakeStore.clearNamespace|clearNamespace(org.mobilenativefoundation.store6.core.StoreNamespace){}[0] + final suspend fun get(#A, org.mobilenativefoundation.store6.core/Freshness): #B // org.mobilenativefoundation.store6.testing/FakeStore.get|get(1:0;org.mobilenativefoundation.store6.core.Freshness){}[0] + final suspend fun invalidate(#A) // org.mobilenativefoundation.store6.testing/FakeStore.invalidate|invalidate(1:0){}[0] + final suspend fun invalidateAll() // org.mobilenativefoundation.store6.testing/FakeStore.invalidateAll|invalidateAll(){}[0] + final suspend fun invalidateNamespace(org.mobilenativefoundation.store6.core/StoreNamespace) // org.mobilenativefoundation.store6.testing/FakeStore.invalidateNamespace|invalidateNamespace(org.mobilenativefoundation.store6.core.StoreNamespace){}[0] +} + +final class org.mobilenativefoundation.store6.testing/FakeBookkeeper : org.mobilenativefoundation.store6.core.seam/Bookkeeper { // org.mobilenativefoundation.store6.testing/FakeBookkeeper|null[0] + constructor () // org.mobilenativefoundation.store6.testing/FakeBookkeeper.|(){}[0] + + final suspend fun advanceGlobalStaleWatermark() // org.mobilenativefoundation.store6.testing/FakeBookkeeper.advanceGlobalStaleWatermark|advanceGlobalStaleWatermark(){}[0] + final suspend fun advanceStaleWatermark(org.mobilenativefoundation.store6.core/StoreNamespace) // org.mobilenativefoundation.store6.testing/FakeBookkeeper.advanceStaleWatermark|advanceStaleWatermark(org.mobilenativefoundation.store6.core.StoreNamespace){}[0] + final suspend fun forget(org.mobilenativefoundation.store6.core/StoreKey) // org.mobilenativefoundation.store6.testing/FakeBookkeeper.forget|forget(org.mobilenativefoundation.store6.core.StoreKey){}[0] + final suspend fun forgetAll() // org.mobilenativefoundation.store6.testing/FakeBookkeeper.forgetAll|forgetAll(){}[0] + final suspend fun forgetNamespace(org.mobilenativefoundation.store6.core/StoreNamespace) // org.mobilenativefoundation.store6.testing/FakeBookkeeper.forgetNamespace|forgetNamespace(org.mobilenativefoundation.store6.core.StoreNamespace){}[0] + final suspend fun markStale(org.mobilenativefoundation.store6.core/StoreKey) // org.mobilenativefoundation.store6.testing/FakeBookkeeper.markStale|markStale(org.mobilenativefoundation.store6.core.StoreKey){}[0] + final suspend fun recordFailure(org.mobilenativefoundation.store6.core/StoreKey, kotlin/Long) // org.mobilenativefoundation.store6.testing/FakeBookkeeper.recordFailure|recordFailure(org.mobilenativefoundation.store6.core.StoreKey;kotlin.Long){}[0] + final suspend fun recordSuccess(org.mobilenativefoundation.store6.core/StoreKey, org.mobilenativefoundation.store6.core/StoreMeta) // org.mobilenativefoundation.store6.testing/FakeBookkeeper.recordSuccess|recordSuccess(org.mobilenativefoundation.store6.core.StoreKey;org.mobilenativefoundation.store6.core.StoreMeta){}[0] + final suspend fun status(org.mobilenativefoundation.store6.core/StoreKey): org.mobilenativefoundation.store6.core.seam/KeyStatus? // org.mobilenativefoundation.store6.testing/FakeBookkeeper.status|status(org.mobilenativefoundation.store6.core.StoreKey){}[0] +} + +final class org.mobilenativefoundation.store6.testing/FakeFetcherInvocation { // org.mobilenativefoundation.store6.testing/FakeFetcherInvocation|null[0] + constructor (org.mobilenativefoundation.store6.core/StoreKey, kotlin/String?) // org.mobilenativefoundation.store6.testing/FakeFetcherInvocation.|(org.mobilenativefoundation.store6.core.StoreKey;kotlin.String?){}[0] + + final val etag // org.mobilenativefoundation.store6.testing/FakeFetcherInvocation.etag|{}etag[0] + final fun (): kotlin/String? // org.mobilenativefoundation.store6.testing/FakeFetcherInvocation.etag.|(){}[0] + final val key // org.mobilenativefoundation.store6.testing/FakeFetcherInvocation.key|{}key[0] + final fun (): org.mobilenativefoundation.store6.core/StoreKey // org.mobilenativefoundation.store6.testing/FakeFetcherInvocation.key.|(){}[0] +} + +final class org.mobilenativefoundation.store6.testing/TestStoreMeta : org.mobilenativefoundation.store6.core/StoreMeta { // org.mobilenativefoundation.store6.testing/TestStoreMeta|null[0] + constructor (kotlin/Long, kotlin/String? = ...) // org.mobilenativefoundation.store6.testing/TestStoreMeta.|(kotlin.Long;kotlin.String?){}[0] + + final val etag // org.mobilenativefoundation.store6.testing/TestStoreMeta.etag|{}etag[0] + final fun (): kotlin/String? // org.mobilenativefoundation.store6.testing/TestStoreMeta.etag.|(){}[0] + final val writtenAtEpochMillis // org.mobilenativefoundation.store6.testing/TestStoreMeta.writtenAtEpochMillis|{}writtenAtEpochMillis[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.testing/TestStoreMeta.writtenAtEpochMillis.|(){}[0] +} + +final class org.mobilenativefoundation.store6.testing/TestWallClock : org.mobilenativefoundation.store6.core.seam/WallClock { // org.mobilenativefoundation.store6.testing/TestWallClock|null[0] + constructor (kotlin/Long = ...) // org.mobilenativefoundation.store6.testing/TestWallClock.|(kotlin.Long){}[0] + + final fun advanceBy(kotlin.time/Duration) // org.mobilenativefoundation.store6.testing/TestWallClock.advanceBy|advanceBy(kotlin.time.Duration){}[0] + final fun nowEpochMillis(): kotlin/Long // org.mobilenativefoundation.store6.testing/TestWallClock.nowEpochMillis|nowEpochMillis(){}[0] + final fun setEpochMillis(kotlin/Long) // org.mobilenativefoundation.store6.testing/TestWallClock.setEpochMillis|setEpochMillis(kotlin.Long){}[0] +} + +sealed class org.mobilenativefoundation.store6.testing/FakeStoreInteraction { // org.mobilenativefoundation.store6.testing/FakeStoreInteraction|null[0] + final class Clear : org.mobilenativefoundation.store6.testing/FakeStoreInteraction { // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.Clear|null[0] + constructor (org.mobilenativefoundation.store6.core/StoreKey) // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.Clear.|(org.mobilenativefoundation.store6.core.StoreKey){}[0] + + final val key // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.Clear.key|{}key[0] + final fun (): org.mobilenativefoundation.store6.core/StoreKey // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.Clear.key.|(){}[0] + } + + final class ClearNamespace : org.mobilenativefoundation.store6.testing/FakeStoreInteraction { // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.ClearNamespace|null[0] + constructor (org.mobilenativefoundation.store6.core/StoreNamespace) // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.ClearNamespace.|(org.mobilenativefoundation.store6.core.StoreNamespace){}[0] + + final val namespace // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.ClearNamespace.namespace|{}namespace[0] + final fun (): org.mobilenativefoundation.store6.core/StoreNamespace // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.ClearNamespace.namespace.|(){}[0] + } + + final class Get : org.mobilenativefoundation.store6.testing/FakeStoreInteraction { // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.Get|null[0] + constructor (org.mobilenativefoundation.store6.core/StoreKey, org.mobilenativefoundation.store6.core/Freshness) // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.Get.|(org.mobilenativefoundation.store6.core.StoreKey;org.mobilenativefoundation.store6.core.Freshness){}[0] + + final val freshness // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.Get.freshness|{}freshness[0] + final fun (): org.mobilenativefoundation.store6.core/Freshness // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.Get.freshness.|(){}[0] + final val key // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.Get.key|{}key[0] + final fun (): org.mobilenativefoundation.store6.core/StoreKey // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.Get.key.|(){}[0] + } + + final class Invalidate : org.mobilenativefoundation.store6.testing/FakeStoreInteraction { // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.Invalidate|null[0] + constructor (org.mobilenativefoundation.store6.core/StoreKey) // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.Invalidate.|(org.mobilenativefoundation.store6.core.StoreKey){}[0] + + final val key // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.Invalidate.key|{}key[0] + final fun (): org.mobilenativefoundation.store6.core/StoreKey // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.Invalidate.key.|(){}[0] + } + + final class InvalidateNamespace : org.mobilenativefoundation.store6.testing/FakeStoreInteraction { // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.InvalidateNamespace|null[0] + constructor (org.mobilenativefoundation.store6.core/StoreNamespace) // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.InvalidateNamespace.|(org.mobilenativefoundation.store6.core.StoreNamespace){}[0] + + final val namespace // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.InvalidateNamespace.namespace|{}namespace[0] + final fun (): org.mobilenativefoundation.store6.core/StoreNamespace // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.InvalidateNamespace.namespace.|(){}[0] + } + + final class Stream : org.mobilenativefoundation.store6.testing/FakeStoreInteraction { // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.Stream|null[0] + constructor (org.mobilenativefoundation.store6.core/StoreKey, org.mobilenativefoundation.store6.core/Freshness) // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.Stream.|(org.mobilenativefoundation.store6.core.StoreKey;org.mobilenativefoundation.store6.core.Freshness){}[0] + + final val freshness // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.Stream.freshness|{}freshness[0] + final fun (): org.mobilenativefoundation.store6.core/Freshness // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.Stream.freshness.|(){}[0] + final val key // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.Stream.key|{}key[0] + final fun (): org.mobilenativefoundation.store6.core/StoreKey // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.Stream.key.|(){}[0] + } + + final object ClearAll : org.mobilenativefoundation.store6.testing/FakeStoreInteraction { // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.ClearAll|null[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.ClearAll.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.ClearAll.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.ClearAll.toString|toString(){}[0] + } + + final object Close : org.mobilenativefoundation.store6.testing/FakeStoreInteraction { // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.Close|null[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.Close.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.Close.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.Close.toString|toString(){}[0] + } + + final object InvalidateAll : org.mobilenativefoundation.store6.testing/FakeStoreInteraction { // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.InvalidateAll|null[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.InvalidateAll.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.InvalidateAll.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.mobilenativefoundation.store6.testing/FakeStoreInteraction.InvalidateAll.toString|toString(){}[0] + } +} + +final object org.mobilenativefoundation.store6.testing/TestStoreResults { // org.mobilenativefoundation.store6.testing/TestStoreResults|null[0] + final fun <#A1: kotlin/Any?> data(#A1, org.mobilenativefoundation.store6.core/Origin = ..., kotlin.time/Duration = ..., kotlin/Boolean = ..., kotlin/Boolean = ...): org.mobilenativefoundation.store6.core/StoreResult.Data<#A1> // org.mobilenativefoundation.store6.testing/TestStoreResults.data|data(0:0;org.mobilenativefoundation.store6.core.Origin;kotlin.time.Duration;kotlin.Boolean;kotlin.Boolean){0§}[0] + final fun conflict(org.mobilenativefoundation.store6.core/StoreMeta?, kotlin/String): org.mobilenativefoundation.store6.core/StoreError.Conflict // org.mobilenativefoundation.store6.testing/TestStoreResults.conflict|conflict(org.mobilenativefoundation.store6.core.StoreMeta?;kotlin.String){}[0] + final fun conversionError(kotlin/String, kotlin/Throwable? = ...): org.mobilenativefoundation.store6.core/StoreError.Conversion // org.mobilenativefoundation.store6.testing/TestStoreResults.conversionError|conversionError(kotlin.String;kotlin.Throwable?){}[0] + final fun error(org.mobilenativefoundation.store6.core/StoreError, kotlin/Boolean = ...): org.mobilenativefoundation.store6.core/StoreResult.Error // org.mobilenativefoundation.store6.testing/TestStoreResults.error|error(org.mobilenativefoundation.store6.core.StoreError;kotlin.Boolean){}[0] + final fun exception(org.mobilenativefoundation.store6.core/StoreError, kotlin/Throwable? = ...): org.mobilenativefoundation.store6.core/StoreException // org.mobilenativefoundation.store6.testing/TestStoreResults.exception|exception(org.mobilenativefoundation.store6.core.StoreError;kotlin.Throwable?){}[0] + final fun fetchError(kotlin/String, kotlin/Throwable? = ...): org.mobilenativefoundation.store6.core/StoreError.Fetch // org.mobilenativefoundation.store6.testing/TestStoreResults.fetchError|fetchError(kotlin.String;kotlin.Throwable?){}[0] + final fun freshnessUnsatisfiable(kotlin/String): org.mobilenativefoundation.store6.core/StoreError.FreshnessUnsatisfiable // org.mobilenativefoundation.store6.testing/TestStoreResults.freshnessUnsatisfiable|freshnessUnsatisfiable(kotlin.String){}[0] + final fun loading(): org.mobilenativefoundation.store6.core/StoreResult.Loading // org.mobilenativefoundation.store6.testing/TestStoreResults.loading|loading(){}[0] + final fun missing(org.mobilenativefoundation.store6.core/StoreKey, kotlin/String): org.mobilenativefoundation.store6.core/StoreError.Missing // org.mobilenativefoundation.store6.testing/TestStoreResults.missing|missing(org.mobilenativefoundation.store6.core.StoreKey;kotlin.String){}[0] + final fun persistenceError(kotlin/String, kotlin/Throwable? = ...): org.mobilenativefoundation.store6.core/StoreError.Persistence // org.mobilenativefoundation.store6.testing/TestStoreResults.persistenceError|persistenceError(kotlin.String;kotlin.Throwable?){}[0] + final fun revalidated(kotlin.time/Duration = ...): org.mobilenativefoundation.store6.core/StoreResult.Revalidated // org.mobilenativefoundation.store6.testing/TestStoreResults.revalidated|revalidated(kotlin.time.Duration){}[0] +} diff --git a/store6-testing/build.gradle.kts b/store6-testing/build.gradle.kts new file mode 100644 index 000000000..92a12a431 --- /dev/null +++ b/store6-testing/build.gradle.kts @@ -0,0 +1,49 @@ +plugins { + id("org.mobilenativefoundation.store.store6.multiplatform") +} + +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + // Kit and fake signatures expose core/seam types. + api(projects.store6Core) + // The kits' plainly inherited @Test members must resolve in consumer test + // compilations on every target; kotlin-test is part of this artifact's API. + api(kotlin("test")) + // The kits' inherited members are expression-bodied `: TestResult = runTest {…}`, + // so kotlinx.coroutines.test.TestResult is in the published ABI on all 12 targets + // (plain inheritance — no expect/actual, no forwarding members on js/wasmJs). + // This is the exact evidence phase0 item 35 conditioned api scope on (approved). + api(libs.kotlinx.coroutines.test) + // The SoT kit BODIES use turbine (app.cash.turbine.test/turbineScope), but turbine + // never appears in a source signature. The declaration is deliberately narrower + // than the landed contract-tests api(libs.turbine). Effective publication scope is + // target-dependent: JVM/Android/JS/Wasm POMs publish it at runtime, while Gradle + // metadata and Native variants place it on API/compile variants. That live tooling + // shape is recorded in the decision doc and remains flagged for Matt. + implementation(libs.turbine) + } + } + val jvmMain by getting { + dependencies { + // Mirrors the landed contract-tests module: JUnit-variant resolution for JVM consumers. + api(kotlin("test-junit")) + } + } + val androidMain by getting { + dependencies { + api(kotlin("test-junit")) + } + } + val commonTest by getting { + dependencies { + implementation(libs.turbine) + } + } + } +} + +android { + namespace = "org.mobilenativefoundation.store6.testing" +} diff --git a/store6-testing/gradle.properties b/store6-testing/gradle.properties new file mode 100644 index 000000000..c89ca67a3 --- /dev/null +++ b/store6-testing/gradle.properties @@ -0,0 +1,3 @@ +VERSION_NAME=6.0.0-SNAPSHOT +POM_NAME=store6-testing +POM_ARTIFACT_ID=store6-testing diff --git a/store6-testing/src/androidMain/AndroidManifest.xml b/store6-testing/src/androidMain/AndroidManifest.xml new file mode 100644 index 000000000..8072ee00d --- /dev/null +++ b/store6-testing/src/androidMain/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/BookkeeperContractKit.kt b/store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/BookkeeperContractKit.kt new file mode 100644 index 000000000..7778599f1 --- /dev/null +++ b/store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/BookkeeperContractKit.kt @@ -0,0 +1,165 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.testing + +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreMeta +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Conformance kit for [Bookkeeper] implementations. Extend it in your test source set and run your + * tests: every inherited @Test member executes on every target you compile. + * + * ``` + * class MyBookkeeperContractTest : BookkeeperContractKit() { + * override fun createBookkeeper() = MyBookkeeper() + * } + * ``` + */ +@ExperimentalStoreApi +public abstract class BookkeeperContractKit { + /** Creates a fresh bookkeeper for one contract test. */ + public abstract fun createBookkeeper(): Bookkeeper + + @Test + public fun identityDerivation_equalCanonicalPair_sharesRecords(): TestResult = + runTest { + val bookkeeper = createBookkeeper() + val first = FirstKey(namespace = StoreNamespace("shared"), id = "key") + val equal = SecondKey(namespaceName = "shared", idParts = listOf("k", "ey")) + val meta = TestStoreMeta(writtenAtEpochMillis = 1L, etag = "v1") + + bookkeeper.recordSuccess(first, meta) + val sharedMeta = requireNotNull(requireNotNull(bookkeeper.status(equal)).meta) + assertEquals(meta.writtenAtEpochMillis, sharedMeta.writtenAtEpochMillis) + assertEquals(meta.etag, sharedMeta.etag) + + bookkeeper.markStale(equal) + assertTrue(requireNotNull(bookkeeper.status(first)).durablyStale) + + bookkeeper.forget(first) + assertNull(bookkeeper.status(equal)) + } + + @Test + public fun identityDerivation_differentCanonicalId_isolated(): TestResult = runTest { + val bookkeeper = createBookkeeper() + val namespace = StoreNamespace("shared") + val first = FirstKey(namespace = namespace, id = "first") + val second = FirstKey(namespace = namespace, id = "second") + val sameIdOtherNamespace = + FirstKey(namespace = StoreNamespace("other"), id = "first") + val meta = TestStoreMeta(writtenAtEpochMillis = 1L, etag = null) + + bookkeeper.recordSuccess(first, meta) + assertNull(bookkeeper.status(sameIdOtherNamespace)) + bookkeeper.markStale(second) + + val firstStatus = requireNotNull(bookkeeper.status(first)) + val firstMeta = requireNotNull(firstStatus.meta) + assertEquals(meta.writtenAtEpochMillis, firstMeta.writtenAtEpochMillis) + assertEquals(meta.etag, firstMeta.etag) + assertFalse(firstStatus.durablyStale) + val secondStatus = requireNotNull(bookkeeper.status(second)) + assertNull(secondStatus.meta) + assertTrue(secondStatus.durablyStale) + } + + @Test + public fun failureOnlyRecord_isNotDurablyStale(): TestResult = runTest { + val bookkeeper = createBookkeeper() + val key = FirstKey(namespace = StoreNamespace("failure"), id = "key") + + bookkeeper.recordFailure(key, atEpochMillis = 10L) + + val status = requireNotNull(bookkeeper.status(key)) + assertNull(status.meta) + assertNull(status.lastSuccessSequence) + assertEquals(10L, status.lastFailureAtEpochMillis) + assertEquals(1, status.consecutiveFailures) + assertFalse(status.durablyStale) + } + + @Test + public fun namespaceWatermark_thenLaterSuccess_clearsDurableStaleness(): TestResult = runTest { + val bookkeeper = createBookkeeper() + val key = FirstKey(namespace = StoreNamespace("watermarked"), id = "key") + val meta = TestStoreMeta(writtenAtEpochMillis = 20L, etag = "fresh") + + bookkeeper.advanceStaleWatermark(StoreNamespace(key.namespace.value)) + assertTrue(requireNotNull(bookkeeper.status(key)).durablyStale) + + bookkeeper.recordSuccess(key, meta) + + val status = requireNotNull(bookkeeper.status(key)) + val statusMeta = requireNotNull(status.meta) + assertEquals(meta.writtenAtEpochMillis, statusMeta.writtenAtEpochMillis) + assertEquals(meta.etag, statusMeta.etag) + assertFalse(status.durablyStale) + } + + @Test + public fun watermarkOnlyKey_reportsDurablyStale(): TestResult = runTest { + val bookkeeper = createBookkeeper() + val namespace = StoreNamespace("watermarked") + val key = FirstKey(namespace = namespace, id = "never-seen") + + bookkeeper.advanceStaleWatermark(StoreNamespace(key.namespace.value)) + + val status = requireNotNull(bookkeeper.status(key)) + assertNull(status.meta) + assertNull(status.lastSuccessSequence) + assertNull(status.lastFailureAtEpochMillis) + assertEquals(0, status.consecutiveFailures) + assertTrue(status.durablyStale) + } + + @Test + public fun recordSuccess_clearsFailureCountAndTimestamp(): TestResult = runTest { + val bookkeeper = createBookkeeper() + val key = FirstKey(namespace = StoreNamespace("failure"), id = "key") + val meta = TestStoreMeta(writtenAtEpochMillis = 30L, etag = "recovered") + bookkeeper.recordFailure(key, atEpochMillis = 10L) + bookkeeper.recordFailure(key, atEpochMillis = 20L) + + bookkeeper.recordSuccess(key, meta) + + val status = requireNotNull(bookkeeper.status(key)) + val statusMeta = requireNotNull(status.meta) + assertEquals(meta.writtenAtEpochMillis, statusMeta.writtenAtEpochMillis) + assertEquals(meta.etag, statusMeta.etag) + assertNull(status.lastFailureAtEpochMillis) + assertEquals(0, status.consecutiveFailures) + } + + private class FirstKey( + override val namespace: StoreNamespace, + private val id: String, + ) : StoreKey { + override fun canonicalId(): String = id + } + + private class SecondKey( + private val namespaceName: String, + private val idParts: List, + ) : StoreKey { + override val namespace: StoreNamespace + get() = StoreNamespace(namespaceName) + + override fun canonicalId(): String = idParts.joinToString(separator = "") + } + + private class TestStoreMeta( + override val writtenAtEpochMillis: Long, + override val etag: String?, + ) : StoreMeta +} diff --git a/store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeBookkeeper.kt b/store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeBookkeeper.kt new file mode 100644 index 000000000..14b8caa33 --- /dev/null +++ b/store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeBookkeeper.kt @@ -0,0 +1,103 @@ +package org.mobilenativefoundation.store6.testing + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreMeta +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.core.seam.KeyStatus + +/** + * In-memory [Bookkeeper] honoring the durable-staleness algebra, certified by + * [BookkeeperContractKit]: one monotone sequence shared by successes, per-key stale marks, and + * watermarks; `durablyStale` is true when max(perKeyMark, namespaceWatermark, globalWatermark) + * exceeds (lastSuccessSequence ?: 0); status() is non-null when a record or a covering watermark + * exists; watermarks never reset. Records are keyed on `(namespace.value, canonicalId())`. + * Share one instance across store instances to simulate process restart. + */ +@ExperimentalStoreApi +@OptIn(DelicateStoreApi::class) +public class FakeBookkeeper : Bookkeeper { + private class Record( + var meta: StoreMeta? = null, + var lastSuccessSequence: Long? = null, + var lastFailureAtEpochMillis: Long? = null, + var consecutiveFailures: Int = 0, + var staleMark: Long = 0L, + ) + + private val mutex = Mutex() + private var sequence = 0L + private val records = HashMap, Record>() + private val namespaceWatermarks = HashMap() + private var globalWatermark = 0L + + private fun idOf(key: StoreKey): Pair = + key.namespace.value to key.canonicalId() + + override suspend fun recordSuccess(key: StoreKey, meta: StoreMeta): Unit = + mutex.withLock { + val record = records.getOrPut(idOf(key)) { Record() } + record.meta = meta + record.lastSuccessSequence = ++sequence + record.consecutiveFailures = 0 + record.lastFailureAtEpochMillis = null + } + + override suspend fun recordFailure(key: StoreKey, atEpochMillis: Long): Unit = + mutex.withLock { + val record = records.getOrPut(idOf(key)) { Record() } + record.lastFailureAtEpochMillis = atEpochMillis + record.consecutiveFailures += 1 + } + + override suspend fun markStale(key: StoreKey): Unit = + mutex.withLock { + records.getOrPut(idOf(key)) { Record() }.staleMark = ++sequence + } + + override suspend fun advanceStaleWatermark(namespace: StoreNamespace): Unit = + mutex.withLock { + namespaceWatermarks[namespace.value] = ++sequence + } + + override suspend fun advanceGlobalStaleWatermark(): Unit = + mutex.withLock { + globalWatermark = ++sequence + } + + override suspend fun status(key: StoreKey): KeyStatus? = + mutex.withLock { + val id = idOf(key) + val record = records[id] + val covering = maxOf(namespaceWatermarks[id.first] ?: 0L, globalWatermark) + if (record == null && covering == 0L) return@withLock null + val mark = maxOf(record?.staleMark ?: 0L, covering) + KeyStatus( + meta = record?.meta, + lastSuccessSequence = record?.lastSuccessSequence, + lastFailureAtEpochMillis = record?.lastFailureAtEpochMillis, + consecutiveFailures = record?.consecutiveFailures ?: 0, + durablyStale = mark > (record?.lastSuccessSequence ?: 0L), + ) + } + + override suspend fun forget(key: StoreKey): Unit = + mutex.withLock { + records.remove(idOf(key)) + Unit + } + + override suspend fun forgetNamespace(namespace: StoreNamespace): Unit = + mutex.withLock { + records.keys.removeAll { it.first == namespace.value } + Unit + } + + override suspend fun forgetAll(): Unit = mutex.withLock { + records.clear() + } +} diff --git a/store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeFetcher.kt b/store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeFetcher.kt new file mode 100644 index 000000000..fb779342a --- /dev/null +++ b/store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeFetcher.kt @@ -0,0 +1,71 @@ +package org.mobilenativefoundation.store6.testing + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.seam.Fetcher +import org.mobilenativefoundation.store6.core.seam.FetcherResult + +/** + * Scripted seam [Fetcher]: per-key FIFO [FetcherResult] queues and invocation recording, including + * the etag the engine passes when it planned a conditional fetch. Unscripted invocations surface + * as a descriptive [FetcherResult.Error]; override [onUnscripted] to change that. + */ +@ExperimentalStoreApi +@OptIn(DelicateStoreApi::class) +public class FakeFetcher : Fetcher { + public var onUnscripted: (key: K, etag: String?) -> FetcherResult = { key, _ -> + FetcherResult.Error( + IllegalStateException( + "FakeFetcher.fetch had no scripted result for key " + + "${key.namespace.value}/${key.canonicalId()}: enqueue(key, ...) a " + + "FetcherResult before the fetch runs or set onUnscripted.", + ), + ) + } + + private val scripts = + MutableStateFlow, List>>>(emptyMap()) + private val recorded = MutableStateFlow>(emptyList()) + + public val invocations: List + get() = recorded.value + + public fun clearInvocations() { + recorded.value = emptyList() + } + + public fun enqueue(key: K, vararg results: FetcherResult) { + val id = idOf(key) + scripts.update { it + (id to (it[id].orEmpty() + results)) } + } + + override suspend fun fetch(key: K, etag: String?): FetcherResult { + recorded.update { it + FakeFetcherInvocation(key, etag) } + return pop(idOf(key)) ?: onUnscripted(key, etag) + } + + private fun idOf(key: K): Pair = + key.namespace.value to key.canonicalId() + + private fun pop(id: Pair): FetcherResult? { + while (true) { + val current = scripts.value + val queue = current[id].orEmpty() + val head = queue.firstOrNull() ?: return null + if (scripts.compareAndSet(current, current + (id to queue.drop(1)))) return head + } + } +} + +/** One recorded [FakeFetcher.fetch] call, appended in invocation order. */ +@ExperimentalStoreApi +public class FakeFetcherInvocation( + /** The key the engine asked for. */ + public val key: StoreKey, + + /** The conditional-request ETag, or null when the engine planned an unconditional fetch. */ + public val etag: String?, +) diff --git a/store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeSourceOfTruth.kt b/store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeSourceOfTruth.kt new file mode 100644 index 000000000..7933e1a59 --- /dev/null +++ b/store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeSourceOfTruth.kt @@ -0,0 +1,72 @@ +package org.mobilenativefoundation.store6.testing + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth + +/** + * In-memory [SourceOfTruth] that passes [SourceOfTruthContractKit] on every target: versioned + * cells (equal-value rewrites still emit), deletes emit null, readers never complete, and rows are + * keyed on `(namespace.value, canonicalId())`. Rows are retained for the instance lifetime (test + * scope). + */ +@ExperimentalStoreApi +@OptIn(DelicateStoreApi::class) +public class FakeSourceOfTruth : SourceOfTruth { + private class Row(val version: Long, val value: V?) + + private val rows = MutableStateFlow, Row>>(emptyMap()) + + private fun idOf(key: K): Pair = + key.namespace.value to key.canonicalId() + + override fun reader(key: K): Flow { + val id = idOf(key) + return rows + .map { it[id] } + .distinctUntilChanged { a, b -> a?.version == b?.version } + .map { it?.value } + } + + override suspend fun write(key: K, value: V) { + val id = idOf(key) + rows.update { map -> + map + (id to Row((map[id]?.version ?: 0L) + 1, value)) + } + } + + override suspend fun delete(key: K) { + val id = idOf(key) + rows.update { map -> + val row = map[id] + if (row?.value == null) map else map + (id to Row(row.version + 1, null)) + } + } + + override suspend fun deleteNamespace(namespace: StoreNamespace) { + rows.update { map -> + map.mapValues { (id, row) -> + if (id.first == namespace.value && row.value != null) { + Row(row.version + 1, null) + } else { + row + } + } + } + } + + override suspend fun deleteAll() { + rows.update { map -> + map.mapValues { (_, row) -> + if (row.value != null) Row(row.version + 1, null) else row + } + } + } +} diff --git a/store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStore.kt b/store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStore.kt new file mode 100644 index 000000000..4c0d8885d --- /dev/null +++ b/store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStore.kt @@ -0,0 +1,520 @@ +package org.mobilenativefoundation.store6.testing + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.yield +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreException +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.StoreResult +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +/** + * Programmable [Store] for deterministic tests: scripted outcomes, recorded interactions, honest + * result flags, and one failure channel. [Freshness] values are recorded but never interpreted; + * compose the seam fakes into a real `store {}` when testing engine policy. + * + * Per-key histories are append-only and unbounded for the lifetime of this test-scoped fake. Every + * history frame is followed by [yield], deliberately providing stronger delivery than the engine + * so StateFlow/stateIn consumers can assert each lifecycle frame without conflation. + * + * A stream is cold and records its interaction when collection starts. A resident key emits an + * immediate Data snapshot without Loading. An absent key emits Loading and consumes at most one + * queued outcome, then remains live. Stream errors are values, never thrown. A fresh [get] returns + * residence without consuming a script; an absent get consumes one outcome, returning a scripted + * value or throwing a [StoreException] through the public result-factory door. A resident + * Revalidated outcome refreshes its write time and clears staleness; without residence it becomes + * Missing on both channels. + * + * Invalidation is a stale-mark only and never consumes a script. A stale Data frame reports + * `refreshing` from whether a script is currently pending. Scripted staleness is consumed at the + * next demand from an active collector, a later stream after its stale snapshot, or behind a + * stale [get] read. One CAS consumption site allows at most one winner across concurrent + * collectors. With no demand, the queued outcome is retained; with no queued outcome, the stale + * frame has `refreshing=false` and no synthetic error. There is no invalidate divergence from the + * engine's demand-deferred posture. + * + * Clear drops residence, emits Loading to active collectors, and retains the script for later + * demand. Namespace and global operations sweep resident keys. Durable watermarks are deliberately + * absent here; compose [FakeBookkeeper] into a real store when testing that engine policy. + * + * Data age is derived from [wallClock] and clamped at zero. [setValue] defaults to + * [Origin.MEMORY], scripted values commit with [Origin.FETCHER], and all stale/refreshing flags are + * computed from current fake state rather than asserted optimistically. + * + * `runtime()` returns null by design. [FakeStore] produces no KeyEvents and performs no overlay + * projection; runtime, events, overlays, and freshness-policy behavior belong to a real store. + * + * [close] is synchronous and idempotent. Active collectors are cancelled; later [Store] operations + * fail immediately, and stream checks closure both when called and when collection starts. Those + * exception types, post-close behavior, and the exact message text are pinned against the engine's + * close lifecycle. + */ +@ExperimentalStoreApi +@OptIn(DelicateStoreApi::class) +public class FakeStore( + /** The clock every write time and age is measured against; advance it to age values. */ + public val wallClock: TestWallClock = TestWallClock(), +) : Store { + private data class Cell( + val value: V?, + val origin: Origin, + val writtenAtEpochMillis: Long, + val isStale: Boolean, + val script: List>, + val history: List>, + ) + + private class HistoryFrame( + val result: StoreResult, + val staleDemandHead: Scripted? = null, + ) + + private sealed class Scripted { + class Value(val value: V) : Scripted() + + class Failure( + val error: StoreError, + val servedStale: Boolean, + ) : Scripted() + + class Revalidated(val age: Duration) : Scripted() + } + + private val cells = MutableStateFlow, Cell>>(emptyMap()) + private val closed = MutableStateFlow(false) + private val recorded = MutableStateFlow>(emptyList()) + + /** Every recorded [Store] call, in call order, since construction or the last clear. */ + public val interactions: List + get() = recorded.value + + /** Empties [interactions]; recording continues for later calls. */ + public fun clearInteractions() { + recorded.value = emptyList() + } + + /** + * Seeds [key] with a resident [value] without consuming any scripted outcome. + * + * The write time is read from [wallClock], so [StoreResult.Data.age] is measured from the + * clock's current reading. [origin] and [isStale] are recorded verbatim on the seeded value. + * Active collectors of [key] receive the resulting Data frame. + */ + public fun setValue( + key: K, + value: V, + origin: Origin = Origin.MEMORY, + isStale: Boolean = false, + ) { + val id = idOf(key) + cells.update { map -> + val cell = map[id] ?: emptyCell() + val now = wallClock.nowEpochMillis() + map + + ( + id to + cell.copy( + value = value, + origin = origin, + writtenAtEpochMillis = now, + isStale = isStale, + history = + cell.history + + HistoryFrame( + dataOf( + value, + origin, + now, + isStale, + refreshing = false, + ), + ), + ) + ) + } + } + + /** + * Appends a scripted successful fetch to [key]'s FIFO queue. + * + * When demand consumes it the cell commits [value] with [Origin.FETCHER] at the current + * [wallClock] reading and clears staleness. Enqueueing alone emits nothing. + */ + public fun enqueueFetchValue(key: K, value: V) { + enqueue(key, Scripted.Value(value)) + } + + /** + * Appends a scripted fetch failure to [key]'s FIFO queue. + * + * When demand consumes it, [error] reaches a stream collector as a [StoreResult.Error] value + * carrying [servedStale]. A [get] with no resident value throws [error] as a [StoreException]; + * a [get] that has residence returns the resident value instead. Either way the resident value + * and its staleness are left as they were. + */ + public fun enqueueFetchError( + key: K, + error: StoreError, + servedStale: Boolean = false, + ) { + enqueue(key, Scripted.Failure(error, servedStale)) + } + + /** + * Appends a scripted not-modified confirmation to [key]'s FIFO queue. + * + * Consuming it against a resident value refreshes the write time, clears staleness, and emits + * [StoreResult.Revalidated] carrying [age]. Consuming it with no resident value is an error on + * both channels instead ([StoreError.Missing]), since there is nothing to confirm. + */ + public fun enqueueFetchRevalidated( + key: K, + age: Duration = Duration.ZERO, + ) { + enqueue(key, Scripted.Revalidated(age)) + } + + override fun stream(key: K, freshness: Freshness): Flow> { + ensureOpen() + return flow { + ensureOpen() + record(FakeStoreInteraction.Stream(key, freshness)) + val id = idOf(key) + val before = cells.value[id] + val initialHead = before?.script?.firstOrNull() + var cursor = before?.history?.size ?: 0 + if (before?.value != null) { + emit( + dataOf( + before.value, + before.origin, + before.writtenAtEpochMillis, + before.isStale, + refreshing = before.isStale && before.script.isNotEmpty(), + ), + ) + // Initial snapshots are dispatch-pinned too: a stale late-collector snapshot must + // reach StateFlow/stateIn consumers before its demand commits the scripted result. + yield() + if (before.isStale && initialHead != null) { + consumeIfStale(key, initialHead) + } + } else { + emit(TestStoreResults.loading()) + yield() + if (initialHead != null) consumeIfAbsent(key, initialHead) + } + combine(cells, closed) { map, isClosed -> map[id] to isClosed } + .collect { (cell, isClosed) -> + if (isClosed) throw CancellationException(CLOSED_MESSAGE) + val history = cell?.history.orEmpty() + while (cursor < history.size) { + val frame = history[cursor] + emit(frame.result) + cursor += 1 + // Per-frame dispatch pin: deliberately stronger test delivery prevents + // StateFlow/stateIn consumers from losing intermediate lifecycle frames. + yield() + // The stale frame carries the script head it advertised. Every collector + // races for that same head, so only one can win and a failure update cannot + // drain the next queued outcome without another demand. + frame.staleDemandHead?.let { consumeIfStale(key, it) } + } + } + } + } + + override suspend fun get(key: K, freshness: Freshness): V { + ensureOpen() + record(FakeStoreInteraction.Get(key, freshness)) + val cell = cells.value[idOf(key)] + val resident = cell?.value + if (resident != null) { + // Stale-while-revalidate mirror: return stale residence and commit one queued outcome + // behind this read. The next read observes the committed refresh. + val head = cell.script.firstOrNull() + if (cell.isStale && head != null) consumeIfStale(key, head) + return resident + } + val head = cell?.script?.firstOrNull() + return when (val consumed = head?.let { consumeIfAbsent(key, it) }) { + is Scripted.Value -> consumed.value + is Scripted.Failure -> throw TestStoreResults.exception(consumed.error) + is Scripted.Revalidated -> + throw missingException( + key, + "a scripted Revalidated confirmed freshness but no resident value exists", + ) + null -> + cells.value[idOf(key)]?.value + ?: throw missingException(key, "no resident value and no scripted fetch exist") + } + } + + override suspend fun invalidate(key: K) { + ensureOpen() + record(FakeStoreInteraction.Invalidate(key)) + invalidateCell(idOf(key)) + } + + override suspend fun invalidateNamespace(namespace: StoreNamespace) { + ensureOpen() + record(FakeStoreInteraction.InvalidateNamespace(namespace)) + cells.value.keys.filter { it.first == namespace.value }.forEach(::invalidateCell) + } + + override suspend fun invalidateAll() { + ensureOpen() + record(FakeStoreInteraction.InvalidateAll) + cells.value.keys.forEach(::invalidateCell) + } + + override suspend fun clear(key: K) { + ensureOpen() + record(FakeStoreInteraction.Clear(key)) + clearCell(idOf(key)) + } + + override suspend fun clearNamespace(namespace: StoreNamespace) { + ensureOpen() + record(FakeStoreInteraction.ClearNamespace(namespace)) + cells.value.keys.filter { it.first == namespace.value }.forEach(::clearCell) + } + + override suspend fun clearAll() { + ensureOpen() + record(FakeStoreInteraction.ClearAll) + cells.value.keys.forEach(::clearCell) + } + + /** Closes this fake synchronously and idempotently. */ + override fun close() { + if (!closed.compareAndSet(expect = false, update = true)) return + record(FakeStoreInteraction.Close) + } + + private fun idOf(key: K): Pair = + key.namespace.value to key.canonicalId() + + private fun emptyCell(): Cell = + Cell( + value = null, + origin = Origin.FETCHER, + writtenAtEpochMillis = 0L, + isStale = false, + script = emptyList(), + history = emptyList(), + ) + + private fun record(interaction: FakeStoreInteraction) { + recorded.update { it + interaction } + } + + private fun ensureOpen() { + if (closed.value) throw IllegalStateException(CLOSED_MESSAGE) + } + + private fun ageOf(writtenAtEpochMillis: Long): Duration = + (wallClock.nowEpochMillis() - writtenAtEpochMillis).coerceAtLeast(0L).milliseconds + + private fun dataOf( + value: V, + origin: Origin, + writtenAt: Long, + isStale: Boolean, + refreshing: Boolean, + ): StoreResult.Data = + TestStoreResults.data(value, origin, ageOf(writtenAt), isStale, refreshing) + + private fun missingException(key: K, reason: String): StoreException = + TestStoreResults.exception( + TestStoreResults.missing( + key, + "FakeStore.get could not return a value for key " + + "${key.namespace.value}/${key.canonicalId()}: $reason. Seed setValue() or " + + "script enqueueFetchValue()/enqueueFetchError() before reading.", + ), + ) + + private fun enqueue(key: K, scripted: Scripted) { + val id = idOf(key) + cells.update { map -> + val cell = map[id] ?: emptyCell() + map + (id to cell.copy(script = cell.script + scripted)) + } + } + + /** + * Consumes the script head when no resident value exists; at most one consumer wins (CAS). + * Owns the absent-Revalidated case because it holds the real key for the Missing payload. + */ + private fun consumeIfAbsent(key: K, expectedHead: Scripted): Scripted? { + val id = idOf(key) + while (true) { + val current = cells.value + val cell = current[id] + if (cell?.value != null) return null + val head = cell?.script?.firstOrNull() ?: return null + if (head !== expectedHead) return null + val next = + when (head) { + is Scripted.Revalidated -> + cell.copy( + script = cell.script.drop(1), + history = + cell.history + + HistoryFrame( + TestStoreResults.error( + TestStoreResults.missing( + key, + "a scripted Revalidated arrived with no resident " + + "value for ${key.namespace.value}/" + + "${key.canonicalId()}. Script " + + "enqueueFetchValue() first or seed setValue().", + ), + servedStale = false, + ), + ), + ) + else -> applyScript(cell, head) + } + if (cells.compareAndSet(current, current + (id to next))) return head + } + } + + /** + * Consumes the script head against a stale resident. This is the single demand-driven CAS + * consumption site for invalidated cells, called from stale [get] demand and from the stream + * collector loop for active or later stream demand. + */ + private fun consumeIfStale(key: K, expectedHead: Scripted) { + val id = idOf(key) + while (true) { + val current = cells.value + val cell = current[id] ?: return + if (cell.value == null || !cell.isStale) return + val head = cell.script.firstOrNull() ?: return + if (head !== expectedHead) return + if (cells.compareAndSet(current, current + (id to applyScript(cell, head)))) return + } + } + + /** + * Applies one scripted outcome. Revalidated requires residence here; [consumeIfAbsent] owns + * the absent case because it holds the key used for the Missing payload. + */ + private fun applyScript(cell: Cell, head: Scripted): Cell { + val now = wallClock.nowEpochMillis() + val rest = cell.script.drop(1) + return when (head) { + is Scripted.Value -> + cell.copy( + value = head.value, + origin = Origin.FETCHER, + writtenAtEpochMillis = now, + isStale = false, + script = rest, + history = + cell.history + + HistoryFrame( + dataOf( + head.value, + Origin.FETCHER, + now, + isStale = false, + refreshing = false, + ), + ), + ) + is Scripted.Failure -> + cell.copy( + script = rest, + history = + cell.history + + HistoryFrame(TestStoreResults.error(head.error, head.servedStale)), + ) + is Scripted.Revalidated -> { + check(cell.value != null) { + "applyScript requires a resident value for Revalidated; " + + "consumeIfAbsent owns the absent case." + } + cell.copy( + writtenAtEpochMillis = now, + isStale = false, + script = rest, + history = cell.history + HistoryFrame(TestStoreResults.revalidated(head.age)), + ) + } + } + } + + /** + * Invalidate is a stale-mark only, the engine's epoch-bump analog. It appends the stale + * re-emission frame for active collectors and never consumes a script; stale get, active + * stream, or later stream demand owns consumption. + */ + private fun invalidateCell(id: Pair) { + cells.update { map -> + val cell = map[id] ?: return@update map + if (cell.value == null) return@update map + val refreshing = cell.script.isNotEmpty() + val demandHead = cell.script.firstOrNull() + map + + ( + id to + cell.copy( + isStale = true, + history = + cell.history + + HistoryFrame( + result = + dataOf( + cell.value, + cell.origin, + cell.writtenAtEpochMillis, + isStale = true, + refreshing = refreshing, + ), + staleDemandHead = demandHead, + ), + ) + ) + } + } + + private fun clearCell(id: Pair) { + cells.update { map -> + val cell = map[id] ?: return@update map + if (cell.value == null) return@update map + map + + ( + id to + cell.copy( + value = null, + isStale = false, + history = cell.history + HistoryFrame(TestStoreResults.loading()), + ) + ) + } + } + + private companion object { + // Core keeps STORE_CLOSED_MESSAGE internal by design (diagnostics are review-gated text, + // not ABI). This literal is pinned by the close conformance tests here and by + // StoreCloseLifecycleTest in store6-core; change both pins together if the message ever + // changes. + private const val CLOSED_MESSAGE = "Store is closed." + } +} diff --git a/store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStoreInteraction.kt b/store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStoreInteraction.kt new file mode 100644 index 000000000..2ad349d8b --- /dev/null +++ b/store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStoreInteraction.kt @@ -0,0 +1,46 @@ +package org.mobilenativefoundation.store6.testing + +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace + +/** + * One [org.mobilenativefoundation.store6.core.Store] call recorded by a [FakeStore]. + * + * Each variant names the operation that produced it and carries that operation's arguments; + * [Freshness] values are recorded verbatim and never interpreted. `FakeStore.interactions` returns + * them in call order and `FakeStore.clearInteractions` empties the list. A stream records its + * [Stream] entry when collection starts, not when the flow is built, and [Close] is recorded at + * most once because closing an already-closed fake has no effect. + */ +@ExperimentalStoreApi +public sealed class FakeStoreInteraction { + public class Stream( + public val key: StoreKey, + public val freshness: Freshness, + ) : FakeStoreInteraction() + + public class Get( + public val key: StoreKey, + public val freshness: Freshness, + ) : FakeStoreInteraction() + + public class Invalidate(public val key: StoreKey) : FakeStoreInteraction() + + public class InvalidateNamespace( + public val namespace: StoreNamespace, + ) : FakeStoreInteraction() + + public data object InvalidateAll : FakeStoreInteraction() + + public class Clear(public val key: StoreKey) : FakeStoreInteraction() + + public class ClearNamespace( + public val namespace: StoreNamespace, + ) : FakeStoreInteraction() + + public data object ClearAll : FakeStoreInteraction() + + public data object Close : FakeStoreInteraction() +} diff --git a/store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/SourceOfTruthContractKit.kt b/store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/SourceOfTruthContractKit.kt new file mode 100644 index 000000000..c8b0f0583 --- /dev/null +++ b/store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/SourceOfTruthContractKit.kt @@ -0,0 +1,315 @@ +@file:OptIn( + kotlinx.coroutines.ExperimentalCoroutinesApi::class, + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, +) + +package org.mobilenativefoundation.store6.testing + +import app.cash.turbine.test +import app.cash.turbine.turbineScope +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * Conformance kit for [SourceOfTruth] implementations. Extend it in your test source set, + * run your tests: every inherited @Test member executes on every target you compile. + * + * ``` + * class MySourceOfTruthContractTest : SourceOfTruthContractKit() { + * override fun createSourceOfTruth() = MySourceOfTruth() + * override val keyA = MyKey("users", "a") + * override val keyB = MyKey("users", "b") + * override val keyOtherNamespace = MyKey("teams", "a") + * override fun value(index: Int) = MyValue("value-$index") + * } + * ``` + */ +@ExperimentalStoreApi +public abstract class SourceOfTruthContractKit { + /** Creates a fresh source of truth for one contract test. */ + public abstract fun createSourceOfTruth(): SourceOfTruth + + /** Returns the first stable test key. */ + public abstract val keyA: K + + /** Returns a stable test key distinct from [keyA] in the same namespace as [keyA]. */ + public abstract val keyB: K + + /** Returns a stable test key in a namespace distinct from [keyA]. */ + public abstract val keyOtherNamespace: K + + /** Returns a stable value for [index], distinct from values returned for other indices. */ + public abstract fun value(index: Int): V + + @Test + public fun readerStaysLiveAcrossDelete(): TestResult = runTest { + val sourceOfTruth = createSourceOfTruth() + val first = value(1) + val second = value(2) + sourceOfTruth.write(keyA, first) + + sourceOfTruth.reader(keyA).test { + assertEquals(first, awaitItem()) + + sourceOfTruth.delete(keyA) + assertNull(awaitItem()) + + sourceOfTruth.write(keyA, second) + assertEquals(second, awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + public fun readerFirstEmissionIsCurrentValue(): TestResult = runTest { + val sourceOfTruth = createSourceOfTruth() + val current = value(1) + sourceOfTruth.write(keyA, current) + + sourceOfTruth.reader(keyA).test { + assertEquals(current, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + public fun readerFirstEmissionIsNullWhenAbsent(): TestResult = runTest { + val sourceOfTruth = createSourceOfTruth() + + sourceOfTruth.reader(keyA).test { + assertNull(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + public fun deleteEmitsNull(): TestResult = runTest { + val sourceOfTruth = createSourceOfTruth() + val current = value(1) + sourceOfTruth.write(keyA, current) + + sourceOfTruth.reader(keyA).test { + assertEquals(current, awaitItem()) + sourceOfTruth.delete(keyA) + assertNull(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + public fun readerNeverCompletesNormally(): TestResult = runTest { + val sourceOfTruth = createSourceOfTruth() + + sourceOfTruth.reader(keyA).test { + assertNull(awaitItem()) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + public fun readerStaysLiveAcrossThreeDeleteCycles(): TestResult = runTest { + val sourceOfTruth = createSourceOfTruth() + + sourceOfTruth.reader(keyA).test { + assertNull(awaitItem()) + repeat(3) { index -> + val current = value(index) + sourceOfTruth.write(keyA, current) + assertEquals(current, awaitItem()) + sourceOfTruth.delete(keyA) + assertNull(awaitItem()) + } + runCurrent() + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + public fun equalValueRewriteEmits(): TestResult = runTest { + val sourceOfTruth = createSourceOfTruth() + val current = value(1) + + sourceOfTruth.reader(keyA).test { + assertNull(awaitItem()) + sourceOfTruth.write(keyA, current) + assertEquals(current, awaitItem()) + sourceOfTruth.write(keyA, current) + assertEquals(current, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + public fun writeIsVisibleToLateReader(): TestResult = runTest { + val sourceOfTruth = createSourceOfTruth() + val current = value(1) + sourceOfTruth.write(keyA, current) + + sourceOfTruth.reader(keyA).test { + assertEquals(current, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + public fun deleteIsVisibleToLateReader(): TestResult = runTest { + val sourceOfTruth = createSourceOfTruth() + sourceOfTruth.write(keyA, value(1)) + sourceOfTruth.delete(keyA) + + sourceOfTruth.reader(keyA).test { + assertNull(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + public fun keysAreIsolated(): TestResult = runTest { + val sourceOfTruth = createSourceOfTruth() + val current = value(1) + + sourceOfTruth.reader(keyB).test { + assertNull(awaitItem()) + sourceOfTruth.write(keyA, current) + runCurrent() + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + sourceOfTruth.reader(keyA).test { + assertEquals(current, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + public fun twoConcurrentReadersBothSeeWrite(): TestResult = runTest { + val sourceOfTruth = createSourceOfTruth() + val current = value(1) + + turbineScope { + val first = sourceOfTruth.reader(keyA).testIn(backgroundScope) + val second = sourceOfTruth.reader(keyA).testIn(backgroundScope) + try { + assertNull(first.awaitItem()) + assertNull(second.awaitItem()) + sourceOfTruth.write(keyA, current) + assertEquals(current, first.awaitItem()) + assertEquals(current, second.awaitItem()) + } finally { + first.cancelAndIgnoreRemainingEvents() + second.cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + public fun deleteNamespaceDeletesOnlyMatchingNamespace(): TestResult = runTest { + val sourceOfTruth = createSourceOfTruth() + val matchingA = value(1) + val matchingB = value(2) + val other = value(3) + sourceOfTruth.write(keyA, matchingA) + sourceOfTruth.write(keyB, matchingB) + sourceOfTruth.write(keyOtherNamespace, other) + + sourceOfTruth.deleteNamespace(keyA.namespace) + + sourceOfTruth.reader(keyA).test { + assertNull(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + sourceOfTruth.reader(keyB).test { + assertNull(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + sourceOfTruth.reader(keyOtherNamespace).test { + assertEquals(other, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + public fun deleteNamespaceEmitsNullToActiveMatchingReader(): TestResult = runTest { + val sourceOfTruth = createSourceOfTruth() + val matchingA = value(1) + val matchingB = value(2) + val otherValue = value(3) + sourceOfTruth.write(keyA, matchingA) + sourceOfTruth.write(keyB, matchingB) + sourceOfTruth.write(keyOtherNamespace, otherValue) + + turbineScope { + val firstMatching = sourceOfTruth.reader(keyA).testIn(backgroundScope) + val secondMatching = sourceOfTruth.reader(keyB).testIn(backgroundScope) + val other = sourceOfTruth.reader(keyOtherNamespace).testIn(backgroundScope) + try { + assertEquals(matchingA, firstMatching.awaitItem()) + assertEquals(matchingB, secondMatching.awaitItem()) + assertEquals(otherValue, other.awaitItem()) + + sourceOfTruth.deleteNamespace(StoreNamespace(keyA.namespace.value)) + + assertNull(firstMatching.awaitItem()) + assertNull(secondMatching.awaitItem()) + runCurrent() + other.expectNoEvents() + } finally { + firstMatching.cancelAndIgnoreRemainingEvents() + secondMatching.cancelAndIgnoreRemainingEvents() + other.cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + public fun deleteAllDeletesEveryNamespaceAndEmitsNull(): TestResult = runTest { + val sourceOfTruth = createSourceOfTruth() + val first = value(1) + val second = value(2) + sourceOfTruth.write(keyA, first) + sourceOfTruth.write(keyOtherNamespace, second) + + turbineScope { + val matching = sourceOfTruth.reader(keyA).testIn(backgroundScope) + val other = sourceOfTruth.reader(keyOtherNamespace).testIn(backgroundScope) + try { + assertEquals(first, matching.awaitItem()) + assertEquals(second, other.awaitItem()) + sourceOfTruth.deleteAll() + assertNull(matching.awaitItem()) + assertNull(other.awaitItem()) + } finally { + matching.cancelAndIgnoreRemainingEvents() + other.cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + public fun readerStaysLiveAcrossNamespaceDelete(): TestResult = runTest { + val sourceOfTruth = createSourceOfTruth() + val first = value(1) + val second = value(2) + sourceOfTruth.write(keyA, first) + + sourceOfTruth.reader(keyA).test { + assertEquals(first, awaitItem()) + sourceOfTruth.deleteNamespace(keyA.namespace) + assertNull(awaitItem()) + sourceOfTruth.write(keyA, second) + assertEquals(second, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } +} diff --git a/store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/TestStoreMeta.kt b/store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/TestStoreMeta.kt new file mode 100644 index 000000000..1b3494f71 --- /dev/null +++ b/store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/TestStoreMeta.kt @@ -0,0 +1,11 @@ +package org.mobilenativefoundation.store6.testing + +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreMeta + +/** Constructible [StoreMeta] for consumer assertions and error payloads. */ +@ExperimentalStoreApi +public class TestStoreMeta( + override val writtenAtEpochMillis: Long, + override val etag: String? = null, +) : StoreMeta diff --git a/store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/TestStoreResults.kt b/store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/TestStoreResults.kt new file mode 100644 index 000000000..21f5ca76e --- /dev/null +++ b/store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/TestStoreResults.kt @@ -0,0 +1,71 @@ +package org.mobilenativefoundation.store6.testing + +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreException +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreMeta +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.seam.StoreResults +import kotlin.time.Duration + +/** + * Test-facing factories with test-friendly defaults; every member delegates to the seam's + * [StoreResults] door (the sanctioned construction path; StoreResult, StoreError, and + * StoreException constructors are internal). Covers all [StoreResult] states and StoreError + * variants. + */ +@ExperimentalStoreApi +public object TestStoreResults { + public fun loading(): StoreResult.Loading = StoreResults.loading() + + public fun data( + value: V, + origin: Origin = Origin.MEMORY, + age: Duration = Duration.ZERO, + isStale: Boolean = false, + refreshing: Boolean = false, + ): StoreResult.Data = StoreResults.data(value, origin, age, isStale, refreshing) + + public fun revalidated(age: Duration = Duration.ZERO): StoreResult.Revalidated = + StoreResults.revalidated(age) + + public fun error( + error: StoreError, + servedStale: Boolean = false, + ): StoreResult.Error = StoreResults.error(error, servedStale) + + public fun exception( + error: StoreError, + cause: Throwable? = null, + ): StoreException = StoreResults.exception(error, cause) + + public fun fetchError( + message: String, + cause: Throwable? = null, + ): StoreError.Fetch = StoreResults.fetchError(message, cause) + + public fun persistenceError( + message: String, + cause: Throwable? = null, + ): StoreError.Persistence = StoreResults.persistenceError(message, cause) + + public fun conversionError( + message: String, + cause: Throwable? = null, + ): StoreError.Conversion = StoreResults.conversionError(message, cause) + + public fun freshnessUnsatisfiable(message: String): StoreError.FreshnessUnsatisfiable = + StoreResults.freshnessUnsatisfiable(message) + + public fun conflict( + serverMeta: StoreMeta?, + message: String, + ): StoreError.Conflict = StoreResults.conflict(serverMeta, message) + + public fun missing( + key: StoreKey, + message: String, + ): StoreError.Missing = StoreResults.missing(key, message) +} diff --git a/store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/TestWallClock.kt b/store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/TestWallClock.kt new file mode 100644 index 000000000..b243bda9a --- /dev/null +++ b/store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/TestWallClock.kt @@ -0,0 +1,27 @@ +package org.mobilenativefoundation.store6.testing + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.seam.WallClock +import kotlin.time.Duration + +/** Controllable [WallClock]: time moves only when a test moves it. */ +@ExperimentalStoreApi +@OptIn(DelicateStoreApi::class) +public class TestWallClock(startEpochMillis: Long = 0L) : WallClock { + private val now = MutableStateFlow(startEpochMillis) + + override fun nowEpochMillis(): Long = now.value + + /** Moves the clock forward by [duration]. */ + public fun advanceBy(duration: Duration) { + now.update { it + duration.inWholeMilliseconds } + } + + /** Sets the clock to an absolute epoch-milliseconds instant. */ + public fun setEpochMillis(epochMillis: Long) { + now.value = epochMillis + } +} diff --git a/store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeBookkeeperAlgebraTest.kt b/store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeBookkeeperAlgebraTest.kt new file mode 100644 index 000000000..07a886834 --- /dev/null +++ b/store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeBookkeeperAlgebraTest.kt @@ -0,0 +1,79 @@ +package org.mobilenativefoundation.store6.testing + +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreNamespace +import kotlin.test.* + +@OptIn(ExperimentalStoreApi::class) +class FakeBookkeeperAlgebraTest { + private val keyA = TestingKey("users", "a") + private val keyB = TestingKey("users", "b") + private val other = TestingKey("teams", "a") + private val meta = TestStoreMeta(writtenAtEpochMillis = 1L) + + @Test + fun failureOnlyRecord_isNotDurablyStale_andCountsConsecutiveFailures() = runTest { + val bk = FakeBookkeeper() + bk.recordFailure(keyA, atEpochMillis = 10L) + bk.recordFailure(keyA, atEpochMillis = 20L) + val status = assertNotNull(bk.status(keyA)) + assertFalse(status.durablyStale) // no success + no covering mark -> false (?: 0 zero floor) + assertEquals(2, status.consecutiveFailures) + assertEquals(20L, status.lastFailureAtEpochMillis) + assertNull(status.lastSuccessSequence) + bk.recordSuccess(keyA, meta) + val cleared = assertNotNull(bk.status(keyA)) + assertEquals(0, cleared.consecutiveFailures) // success resets the streak + assertNull(cleared.lastFailureAtEpochMillis) // AND the timestamp (kit pin) + } + + @Test + fun perKeyMark_thenSuccess_clearsDurableStaleness() = runTest { + val bk = FakeBookkeeper() + bk.recordSuccess(keyA, meta) + bk.markStale(keyA) + assertTrue(assertNotNull(bk.status(keyA)).durablyStale) + bk.recordSuccess(keyA, meta) // 304-style confirmation assigns a post-mark sequence + assertFalse(assertNotNull(bk.status(keyA)).durablyStale) + } + + @Test + fun namespaceWatermark_coversItsNamespaceOnly_includingRecordlessKeys() = runTest { + val bk = FakeBookkeeper() + bk.recordSuccess(keyA, meta) + bk.recordSuccess(other, meta) + bk.advanceStaleWatermark(StoreNamespace("users")) + assertTrue(assertNotNull(bk.status(keyA)).durablyStale) + assertFalse(assertNotNull(bk.status(other)).durablyStale) // other namespace untouched + assertTrue(assertNotNull(bk.status(keyB)).durablyStale) // record-less key: status non-null via covering watermark + bk.recordSuccess(keyA, meta) // post-watermark success clears coverage + assertFalse(assertNotNull(bk.status(keyA)).durablyStale) + } + + @Test + fun globalWatermark_coversEverything() = runTest { + val bk = FakeBookkeeper() + bk.recordSuccess(other, meta) + bk.advanceGlobalStaleWatermark() + assertTrue(assertNotNull(bk.status(other)).durablyStale) + assertTrue(assertNotNull(bk.status(keyA)).durablyStale) // no record needed + } + + @Test + fun forgetVariants_dropRecords_butWatermarksNeverReset() = runTest { + val bk = FakeBookkeeper() + bk.recordSuccess(keyA, meta) + bk.recordSuccess(other, meta) + bk.advanceStaleWatermark(StoreNamespace("users")) + bk.forgetNamespace(StoreNamespace("users")) + val statusA = assertNotNull(bk.status(keyA)) // record gone; watermark coverage persists + assertNull(statusA.lastSuccessSequence) + assertTrue(statusA.durablyStale) + assertFalse(assertNotNull(bk.status(other)).durablyStale) // other namespace record intact + bk.forget(other) + bk.advanceGlobalStaleWatermark() + bk.forgetAll() + assertTrue(assertNotNull(bk.status(other)).durablyStale) // global watermark survives forgetAll + } +} diff --git a/store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeBookkeeperContractTest.kt b/store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeBookkeeperContractTest.kt new file mode 100644 index 000000000..8b294e381 --- /dev/null +++ b/store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeBookkeeperContractTest.kt @@ -0,0 +1,10 @@ +package org.mobilenativefoundation.store6.testing + +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.seam.Bookkeeper + +@OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) +class FakeBookkeeperContractTest : BookkeeperContractKit() { + override fun createBookkeeper(): Bookkeeper = FakeBookkeeper() +} diff --git a/store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeBookkeeperRestartTest.kt b/store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeBookkeeperRestartTest.kt new file mode 100644 index 000000000..c6b391f4b --- /dev/null +++ b/store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeBookkeeperRestartTest.kt @@ -0,0 +1,34 @@ +package org.mobilenativefoundation.store6.testing + +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import org.mobilenativefoundation.store6.core.store +import kotlin.test.* + +@OptIn(ExperimentalStoreApi::class) +class FakeBookkeeperRestartTest { + @Test + fun durableStaleness_survivesStoreRestart_throughSharedFakes() = runTest { + val key = TestingKey("users", "1") + val sot = FakeSourceOfTruth() + val bookkeeper = FakeBookkeeper() + val fetcher = FakeFetcher() + fetcher.enqueue(key, FetcherResult.Success("v1")) + + val storeA = store { fetcher(fetcher); persistence(sot); bookkeeper(bookkeeper) } + assertEquals("v1", storeA.get(key)) + storeA.invalidate(key) // durable mark lands in the shared FakeBookkeeper (markStale under write lock) + storeA.close() + + assertTrue(bookkeeper.status(key)!!.durablyStale) // the durable fact survived close() + + fetcher.enqueue(key, FetcherResult.Success("v2")) + val storeB = store { fetcher(fetcher); persistence(sot); bookkeeper(bookkeeper) } + assertEquals("v2", storeB.get(key, Freshness.MustBeFresh)) // restart refetches + assertFalse(bookkeeper.status(key)!!.durablyStale) // recordSuccess cleared the mark + assertEquals(2, fetcher.invocations.size) + storeB.close() + } +} diff --git a/store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeFetcherIntegrationTest.kt b/store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeFetcherIntegrationTest.kt new file mode 100644 index 000000000..e2e4bdbbf --- /dev/null +++ b/store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeFetcherIntegrationTest.kt @@ -0,0 +1,46 @@ +package org.mobilenativefoundation.store6.testing + +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.* +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import kotlin.test.* + +@OptIn(ExperimentalStoreApi::class) +class FakeFetcherIntegrationTest { + @Test + fun fakeFetcher_drivesRealStore_andRecordsInvocations() = runTest { + val key = TestingKey("test", "1") + val fetcher = FakeFetcher() + fetcher.enqueue(key, FetcherResult.Success("v1", etag = "e1")) + val store = store { fetcher(fetcher) } + assertEquals("v1", store.get(key)) + val invocation = fetcher.invocations.single() + assertEquals("1", invocation.key.canonicalId()) + assertNull(invocation.etag) // first fetch is unconditional + store.close() + } + + @Test + fun conditionalRevalidation_recordsTheEtag() = runTest { + val key = TestingKey("test", "3") + val fetcher = FakeFetcher() + fetcher.enqueue(key, FetcherResult.Success("v1", etag = "e1")) + val store = store { fetcher(fetcher) } + assertEquals("v1", store.get(key)) // unconditional commit stores etag e1 + fetcher.enqueue(key, FetcherResult.NotModified(etag = "e1")) + assertEquals("v1", store.get(key, Freshness.MustBeFresh)) // MustBeFresh + resident etag -> + // FetchPlan.Conditional("e1"); 304 counts as fresh + assertEquals(listOf(null, "e1"), fetcher.invocations.map { it.etag }) + store.close() + } + + @Test + fun unscriptedInvocation_surfacesAsTypedFetchError() = runTest { + val fetcher = FakeFetcher() + val store = store { fetcher(fetcher) } + val ex = assertFailsWith { store.get(TestingKey("test", "2")) } + assertIs(ex.error) + assertTrue(ex.cause?.message.orEmpty().contains("test/2")) // FakeFetcher names the key and fix + store.close() + } +} diff --git a/store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeSourceOfTruthContractTest.kt b/store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeSourceOfTruthContractTest.kt new file mode 100644 index 000000000..5a8ab3134 --- /dev/null +++ b/store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeSourceOfTruthContractTest.kt @@ -0,0 +1,14 @@ +package org.mobilenativefoundation.store6.testing + +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth + +@OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) +class FakeSourceOfTruthContractTest : SourceOfTruthContractKit() { + override fun createSourceOfTruth(): SourceOfTruth = FakeSourceOfTruth() + override val keyA = TestingKey("kit", "a") + override val keyB = TestingKey("kit", "b") + override val keyOtherNamespace = TestingKey("kit-other", "a") + override fun value(index: Int): String = "value-$index" +} diff --git a/store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeStoreConformanceTest.kt b/store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeStoreConformanceTest.kt new file mode 100644 index 000000000..f0f02cc0c --- /dev/null +++ b/store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeStoreConformanceTest.kt @@ -0,0 +1,411 @@ +package org.mobilenativefoundation.store6.testing + +import app.cash.turbine.test +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.* +import kotlin.test.* +import kotlin.time.Duration.Companion.minutes + +/** + * Read-core conformance coverage for [FakeStore]. + * + * | Posture | Behaviors | + * |---|---| + * | Applicable | Cold-stream sequence, value/error channels, replay, resident serve, one-time + * script consumption, stale-get SWR, demand-deferred invalidation, clear transitions, + * Revalidated cycles, namespace isolation, close semantics, clock-derived age, and + * lifecycle-frame survival through StateFlow/stateIn. | + * | Engine-only | Fetch cancellation and non-cooperative fetchers, freshness-policy matrices, + * dispatcher contention, backpressure conflation, overlay projection, key events, and runtime + * access. Test those by composing the seam fakes into a real store. | + * + * There is no invalidate-divergence row: the fake is aligned to the engine's stale-mark-only, + * next-demand consumption posture. + */ +@OptIn(ExperimentalStoreApi::class) +class FakeStoreConformanceTest { + private val key = TestingKey("test", "1") + + @Test + fun coldStream_absentKey_emitsLoadingThenScriptedDataFromFetcher() = runTest { + val store = FakeStore() + store.enqueueFetchValue(key, "v") + store.stream(key).test { + assertIs(awaitItem()) + val data = assertIs>(awaitItem()) + assertEquals("v", data.value) + assertEquals(Origin.FETCHER, data.origin) + expectNoEvents() // live, not completed + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + @Test + fun scriptedError_streamEmitsErrorAndStaysLive_neverThrows() = runTest { + val store = FakeStore() + store.enqueueFetchError(key, TestStoreResults.fetchError("fetch test/1 failed: scripted failure. Adjust the script.")) + store.stream(key).test { + assertIs(awaitItem()) + val error = assertIs(awaitItem()) + assertIs(error.error) + assertFalse(error.servedStale) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + @Test + fun scriptedError_getThrowsStoreExceptionWithSameCategory() = runTest { + val store = FakeStore() + store.enqueueFetchError(key, TestStoreResults.fetchError("fetch test/1 failed: scripted failure. Adjust the script.")) + val ex = assertFailsWith { store.get(key) } + assertIs(ex.error) + store.close() + } + + @Test + fun absentKey_noScript_getThrowsMissingWithFixMessage() = runTest { + val store = FakeStore() + val ex = assertFailsWith { store.get(key) } + assertIs(ex.error) + assertTrue(ex.message!!.contains("test/1")) // which key + assertTrue(ex.message!!.contains("enqueueFetchValue")) // the fix + store.close() + } + + @Test + fun residentValue_lateCollector_receivesDataImmediatelyWithoutLoading() = runTest { + val store = FakeStore() + store.setValue(key, "v") + store.stream(key).test { + val first = assertIs>(awaitItem()) + assertEquals("v", first.value) + assertEquals(Origin.MEMORY, first.origin) + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + @Test + fun freshResident_getServesResidentWithoutConsumingScript() = runTest { + val store = FakeStore() + store.setValue(key, "resident") + store.enqueueFetchValue(key, "scripted") + assertEquals("resident", store.get(key)) + store.clear(key) + assertEquals("scripted", store.get(key)) // script only consumed on the post-clear demand + store.close() + } + + @Test + fun staleResident_getServesStaleThenConsumesRefresh() = runTest { + val store = FakeStore() + store.setValue(key, "v1", isStale = true) + store.enqueueFetchValue(key, "v2") + assertEquals("v1", store.get(key)) // SWR mirror: stale served immediately, refresh consumed behind + assertEquals("v2", store.get(key)) // the consumed refresh committed + store.close() + } + + @Test + fun twoConcurrentCollectors_oneScriptedOutcomeConsumedOnce() = runTest { + val store = FakeStore() + store.setValue(key, "v1", isStale = true) + store.enqueueFetchError(key, TestStoreResults.fetchError("first scripted failure")) + store.enqueueFetchValue(key, "v2") + val aReady = CompletableDeferred() + val bReady = CompletableDeferred() + val release = CompletableDeferred() + val a = + backgroundScope.async { + store.stream(key) + .onEach { + if (it is StoreResult.Data && it.isStale) { + aReady.complete(Unit) + release.await() + } + }.take(2) + .toList() + } + val b = + backgroundScope.async { + store.stream(key) + .onEach { + if (it is StoreResult.Data && it.isStale) { + bReady.complete(Unit) + release.await() + } + }.take(2) + .toList() + } + aReady.await() + bReady.await() + release.complete(Unit) + val aResults = a.await() + val bResults = b.await() + assertIs(aResults.single { it is StoreResult.Error }) + assertIs(bResults.single { it is StoreResult.Error }) + assertEquals("v1", store.get(key)) // the one winning demand consumed only the failure + assertEquals("v2", store.get(key)) // the next demand consumed the retained value + store.close() + } + + @Test + fun invalidate_activeStream_observesStaleDataThenScriptedRefetch_honestFlags() = runTest { + // The ACTIVE collector IS demand (the engine's active-stream replan analog) — it + // observes the stale frame, then drives consumption of the queued script. + val store = FakeStore() + store.setValue(key, "v1") + store.stream(key).test { + assertEquals("v1", assertIs>(awaitItem()).value) + store.enqueueFetchValue(key, "v2") + store.invalidate(key) + val stale = assertIs>(awaitItem()) + assertEquals("v1", stale.value) + assertTrue(stale.isStale) + assertTrue(stale.refreshing) + val fresh = assertIs>(awaitItem()) + assertEquals("v2", fresh.value) + assertFalse(fresh.isStale) + assertFalse(fresh.refreshing) + assertEquals(Origin.FETCHER, fresh.origin) + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + @Test + fun invalidate_withoutActiveDemand_getServesStaleThenRefreshCommitsBehind() = runTest { + // With NO active demand, + // invalidate defers scripted-staleness consumption to the next demand — engine parity: + // get-after-invalidate serves the STALE value and fires the SWR refresh behind it. Under + // the rejected eager design the first get would have returned "v2". + val store = FakeStore() + store.setValue(key, "v1") + store.enqueueFetchValue(key, "v2") + store.invalidate(key) // stale-mark only; script NOT consumed + assertEquals("v1", store.get(key)) // stale served; consumption happens behind this read + assertEquals("v2", store.get(key)) // the refresh committed + store.close() + } + + @Test + fun invalidate_withoutActiveDemand_scriptRetainedForNextCollector() = runTest { + // The deferred script is consumed by the NEXT stream demand, which first observes the + // honest stale snapshot (isStale=true, refreshing=true because a script waits). + val store = FakeStore() + store.setValue(key, "v1") + store.enqueueFetchValue(key, "v2") + store.invalidate(key) + store.stream(key).test { + val stale = assertIs>(awaitItem()) + assertEquals("v1", stale.value) + assertTrue(stale.isStale) + assertTrue(stale.refreshing) + assertEquals("v2", assertIs>(awaitItem()).value) + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + @Test + fun invalidate_noScript_staleMarkOnly_noConsumptionNoError() = runTest { + // Invalidate with an empty script is a pure stale-mark (epoch-bump analog); + // active collectors observe isStale=true, refreshing=false, and nothing else happens. + val store = FakeStore() + store.setValue(key, "v1") + store.stream(key).test { + assertEquals("v1", assertIs>(awaitItem()).value) + store.invalidate(key) + val stale = assertIs>(awaitItem()) + assertTrue(stale.isStale) + assertFalse(stale.refreshing) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + assertEquals("v1", store.get(key)) // stale resident still served; no script to consume + store.close() + } + + @Test + fun invalidate_activeDemand_consumesOnlyOneFailure_thenRetainsNextScript() = runTest { + val store = FakeStore() + store.setValue(key, "v1") + store.stream(key).test { + assertEquals("v1", assertIs>(awaitItem()).value) + store.enqueueFetchError(key, TestStoreResults.fetchError("first scripted failure")) + store.enqueueFetchValue(key, "v2") + store.invalidate(key) + assertTrue(assertIs>(awaitItem()).isStale) + assertIs(awaitItem()) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + assertEquals("v1", store.get(key)) + assertEquals("v2", store.get(key)) + store.close() + } + + @Test + fun revalidated_withResident_streamEmitsRevalidatedAndRefreshesFreshness() = runTest { + val store = FakeStore() + store.setValue(key, "v") + store.wallClock.advanceBy(5.minutes) + store.enqueueFetchRevalidated(key, age = 5.minutes) + store.stream(key).test { + assertEquals(5.minutes, assertIs>(awaitItem()).age) + store.invalidate(key) + assertTrue(assertIs>(awaitItem()).isStale) // stale frame first + assertEquals(5.minutes, assertIs(awaitItem()).age) // active demand consumed it + cancelAndIgnoreRemainingEvents() + } + store.wallClock.advanceBy(2.minutes) + store.stream(key).test { + val data = assertIs>(awaitItem()) + assertFalse(data.isStale) // 304 cleared staleness + assertEquals(2.minutes, data.age) // age from the refreshed writtenAt + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + @Test + fun revalidated_withoutResident_isMissingOnBothChannels() = runTest { + val store = FakeStore() + store.enqueueFetchRevalidated(key) + store.stream(key).test { + assertIs(awaitItem()) + assertIs(assertIs(awaitItem()).error) + cancelAndIgnoreRemainingEvents() + } + store.enqueueFetchRevalidated(key) + val ex = assertFailsWith { store.get(key) } + assertIs(ex.error) + store.close() + } + + @Test + fun clear_activeStream_observesAbsentTransition() = runTest { + val store = FakeStore() + store.setValue(key, "v1") + store.stream(key).test { + assertIs>(awaitItem()) + store.clear(key) + assertIs(awaitItem()) // honest absent transition + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + @Test + fun namespaceSweeps_touchOnlyMatchingNamespace() = runTest { + val store = FakeStore() + val other = TestingKey("other", "1") + store.setValue(key, "a") + store.setValue(other, "b") + store.clearNamespace(StoreNamespace("test")) + assertFailsWith { store.get(key) } + assertEquals("b", store.get(other)) + store.invalidateNamespace(StoreNamespace("other")) + store.stream(other).test { + assertTrue(assertIs>(awaitItem()).isStale) + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + @Test + fun lifecycleFrames_surviveStateFlowConsumer() = runTest { + // Pins per-frame dispatch: a stateIn consumer (the dominant ViewModel shape) observes + // every lifecycle frame — the StateFlow-conflation trap cannot swallow the + // stale/refreshing frame. The eagerly-shared collector is ACTIVE demand, so it drives + // consumption of the queued script after the stale frame. + val store = FakeStore() + store.setValue(key, "v1") + val state: StateFlow?> = + store.stream(key).stateIn(backgroundScope, SharingStarted.Eagerly, null) + state.filterNotNull().test { + assertEquals("v1", assertIs>(awaitItem()).value) + store.enqueueFetchError(key, TestStoreResults.fetchError("refresh test/1 failed: scripted. Adjust the script.")) + store.invalidate(key) + val stale = assertIs>(awaitItem()) + assertTrue(stale.isStale) + assertTrue(stale.refreshing) + assertIs(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + @Test + fun staleBeforeCollection_lifecycleFramesSurviveStateFlowConsumer() = runTest { + val store = FakeStore() + store.setValue(key, "v1") + store.enqueueFetchValue(key, "v2") + store.invalidate(key) + val state: StateFlow?> = + store.stream(key).stateIn(backgroundScope, SharingStarted.Lazily, null) + state.filterNotNull().test { + val stale = assertIs>(awaitItem()) + assertEquals("v1", stale.value) + assertTrue(stale.isStale) + assertTrue(stale.refreshing) + val fresh = assertIs>(awaitItem()) + assertEquals("v2", fresh.value) + assertFalse(fresh.isStale) + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + @Test + fun close_isIdempotent_andOperationsAfterCloseFailFast() = runTest { + // Pins verified against StoreCloseLifecycleTest in store6-core. + val store = FakeStore() + store.close() + store.close() // no additional effect + assertEquals("Store is closed.", assertFailsWith { store.get(key) }.message) + assertEquals("Store is closed.", assertFailsWith { store.stream(key) }.message) + assertEquals(1, store.interactions.filterIsInstance().size) + } + + @Test + fun close_cancelsActiveCollectors() = runTest { + // Pins verified against StoreCloseLifecycleTest in store6-core. + val store = FakeStore() + store.setValue(key, "v") + val started = CompletableDeferred() + val collector = backgroundScope.async { + runCatching { store.stream(key).collect { started.complete(Unit) } } + } + started.await() + store.close() + val failure = collector.await().exceptionOrNull() + assertIs(failure) + assertEquals("Store is closed.", failure.message) + } + + @Test + fun age_derivesFromTestWallClock() = runTest { + val store = FakeStore() + store.setValue(key, "v") + store.wallClock.advanceBy(5.minutes) + store.stream(key).test { + assertEquals(5.minutes, assertIs>(awaitItem()).age) + cancelAndIgnoreRemainingEvents() + } + store.close() + } +} diff --git a/store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/TestStoreResultsTest.kt b/store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/TestStoreResultsTest.kt new file mode 100644 index 000000000..2cca8322f --- /dev/null +++ b/store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/TestStoreResultsTest.kt @@ -0,0 +1,33 @@ +package org.mobilenativefoundation.store6.testing + +import org.mobilenativefoundation.store6.core.* +import kotlin.test.* +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalStoreApi::class) +class TestStoreResultsTest { + @Test + fun factories_constructEveryResultStateAndErrorVariant_withoutInternals() { + val key = TestingKey("test", "1") + val results: List> = listOf( + TestStoreResults.loading(), + TestStoreResults.data("v"), + TestStoreResults.data("v", origin = Origin.SOT, age = 5.seconds, isStale = true, refreshing = true), + TestStoreResults.revalidated(), + TestStoreResults.error(TestStoreResults.fetchError("fetch test/1 failed: scripted. Fix the script.")), + TestStoreResults.error(TestStoreResults.persistenceError("persist test/1 failed: scripted. Fix the script."), servedStale = true), + TestStoreResults.error(TestStoreResults.conversionError("convert test/1 failed: scripted. Fix the script.")), + TestStoreResults.error(TestStoreResults.freshnessUnsatisfiable("MustBeFresh for test/1 failed: scripted. Fix the script.")), + TestStoreResults.error(TestStoreResults.conflict(TestStoreMeta(writtenAtEpochMillis = 1L, etag = "e"), "write test/1 conflicted: scripted. Fix the script.")), + TestStoreResults.error(TestStoreResults.missing(key, "no value for test/1: scripted. Fix the script.")), + ) + results.forEach { result -> + when (result) { // exhaustive consumer-side when over the sealed vocabulary + is StoreResult.Loading, is StoreResult.Data, is StoreResult.Revalidated, is StoreResult.Error -> Unit + } + } + val exception = TestStoreResults.exception(TestStoreResults.missing(key, "no value for test/1: scripted. Fix the script.")) + assertIs(exception.error) + assertTrue(exception.message!!.contains("test/1")) + } +} diff --git a/store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/TestingKey.kt b/store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/TestingKey.kt new file mode 100644 index 000000000..44a173998 --- /dev/null +++ b/store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/TestingKey.kt @@ -0,0 +1,9 @@ +package org.mobilenativefoundation.store6.testing + +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace + +class TestingKey(ns: String, private val id: String) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace(ns) + override fun canonicalId(): String = id +} diff --git a/store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/UserViewModelSampleTest.kt b/store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/UserViewModelSampleTest.kt new file mode 100644 index 000000000..1fd2cb016 --- /dev/null +++ b/store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/UserViewModelSampleTest.kt @@ -0,0 +1,62 @@ +package org.mobilenativefoundation.store6.testing + +import app.cash.turbine.test +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.runningFold +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.* +import kotlin.test.* + +private class UserKey(private val id: String) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("users") + override fun canonicalId(): String = id +} +private class User(val id: String, val name: String) + +private sealed class UserUiState { + data object Loading : UserUiState() + class Ready(val name: String, val refreshing: Boolean) : UserUiState() + class Failed(val error: StoreError) : UserUiState() +} + +// docs:snippet:guides-testing-fake-store-view-model +private class UserViewModel(store: Store, key: UserKey, scope: CoroutineScope) { + val state: StateFlow = + store.stream(key) + .runningFold, UserUiState>(UserUiState.Loading) { previous, result -> + when (result) { + is StoreResult.Loading -> UserUiState.Loading + is StoreResult.Data -> UserUiState.Ready(result.value.name, result.refreshing) + is StoreResult.Revalidated -> previous // still fresh; keep what is shown + is StoreResult.Error -> UserUiState.Failed(result.error) + } + } + .stateIn(scope, SharingStarted.Eagerly, UserUiState.Loading) +} + +@OptIn(ExperimentalStoreApi::class) +class UserViewModelSampleTest { + @Test + fun viewModel_rendersLoadingDataAndErrorFromFakeStore() = runTest { + val fake = FakeStore() + val key = UserKey("42") + fake.enqueueFetchValue(key, User("42", "Matt")) + val vm = UserViewModel(fake, key, backgroundScope) + vm.state.test { + assertIs(awaitItem()) + assertEquals("Matt", assertIs(awaitItem()).name) + + fake.enqueueFetchError(key, TestStoreResults.fetchError("refresh users/42 failed: server 500. Retry later."), servedStale = true) + fake.invalidate(key) + assertTrue(assertIs(awaitItem()).refreshing) // stale shown while refreshing + assertIs(assertIs(awaitItem()).error) + cancelAndIgnoreRemainingEvents() + } + assertEquals("42", fake.interactions.filterIsInstance().single().key.canonicalId()) + fake.close() + } +} +// docs:snippet:end diff --git a/store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/docs/GuideTestingSnippets.kt b/store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/docs/GuideTestingSnippets.kt new file mode 100644 index 000000000..c080f2572 --- /dev/null +++ b/store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/docs/GuideTestingSnippets.kt @@ -0,0 +1,79 @@ +@file:OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) +@file:Suppress("unused") + +package org.mobilenativefoundation.store6.testing.docs + +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.store +import org.mobilenativefoundation.store6.testing.BookkeeperContractKit +import org.mobilenativefoundation.store6.testing.FakeBookkeeper +import org.mobilenativefoundation.store6.testing.FakeFetcher +import org.mobilenativefoundation.store6.testing.FakeSourceOfTruth +import org.mobilenativefoundation.store6.testing.SourceOfTruthContractKit +import org.mobilenativefoundation.store6.testing.TestWallClock + +public class UserKey( + private val id: String, +) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("users") + + override fun canonicalId(): String = id +} + +public data class User( + val id: String, + val name: String, +) + +// docs:snippet:guides-testing-policy-fixtures +@OptIn(ExperimentalStoreApi::class) +fun userStoreForPolicyTests(): Store { + val fetcher = FakeFetcher() + val sourceOfTruth = FakeSourceOfTruth() + val bookkeeper = FakeBookkeeper() + val clock = TestWallClock() + + return store { + fetcher(fetcher) + persistence(sourceOfTruth) + bookkeeper(bookkeeper) + wallClock(clock) + } +} +// docs:snippet:end + +public class MyKey( + namespace: String, + private val id: String, +) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace(namespace) + + override fun canonicalId(): String = id +} + +public data class MyValue( + val value: String, +) + +public typealias MySourceOfTruth = FakeSourceOfTruth +public typealias MyBookkeeper = FakeBookkeeper + +// docs:snippet:guides-source-of-truth-contract-kit +class MySourceOfTruthContractTest : SourceOfTruthContractKit() { + override fun createSourceOfTruth() = MySourceOfTruth() + override val keyA = MyKey("users", "a") + override val keyB = MyKey("users", "b") + override val keyOtherNamespace = MyKey("teams", "a") + override fun value(index: Int) = MyValue("value-$index") +} +// docs:snippet:end + +// docs:snippet:guides-testing-bookkeeper-contract-kit +class MyBookkeeperContractTest : BookkeeperContractKit() { + override fun createBookkeeper() = MyBookkeeper() +} +// docs:snippet:end diff --git a/tooling/plugins/build.gradle.kts b/tooling/plugins/build.gradle.kts index d0013b351..0943bc484 100644 --- a/tooling/plugins/build.gradle.kts +++ b/tooling/plugins/build.gradle.kts @@ -1,38 +1,33 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `kotlin-dsl` - alias(libs.plugins.ktlint) } group = "org.mobilenativefoundation.store" java { - val compatVersion = JavaVersion.toVersion(libs.versions.jvmToolchain.get()) - sourceCompatibility = compatVersion - targetCompatibility = compatVersion - - toolchain { - languageVersion.set(JavaLanguageVersion.of(libs.versions.jvmToolchain.get())) - } + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } -kotlin { - compilerOptions { - jvmTarget = JvmTarget.fromTarget(libs.versions.jvmToolchain.get()) - freeCompilerArgs.add("-Xcontext-parameters") - } +tasks.withType().configureEach { + compilerOptions.jvmTarget.set(JvmTarget.JVM_11) } dependencies { compileOnly(libs.android.gradle.plugin) + compileOnly(libs.binary.compatibility.validator) compileOnly(libs.kotlin.gradle.plugin) compileOnly(libs.dokka.gradle.plugin) compileOnly(libs.maven.publish.plugin) compileOnly(libs.kmmBridge.gradle.plugin) compileOnly(libs.atomic.fu.gradle.plugin) - compileOnly(libs.ktlint.gradle.plugin) - compileOnly(libs.spotless.gradle.plugin) + + testImplementation(kotlin("test", embeddedKotlinVersion)) + testImplementation(libs.junit) + testImplementation(gradleTestKit()) } gradlePlugin { @@ -42,22 +37,29 @@ gradlePlugin { implementationClass = "org.mobilenativefoundation.store.tooling.plugins.KotlinMultiplatformConventionPlugin" } + register("store6MultiplatformConventionPlugin") { + id = "org.mobilenativefoundation.store.store6.multiplatform" + implementationClass = "org.mobilenativefoundation.store.tooling.plugins.Store6MultiplatformConventionPlugin" + } + + register("store6MultiplatformSubsetConventionPlugin") { + id = "org.mobilenativefoundation.store.store6.multiplatform.subset" + implementationClass = "org.mobilenativefoundation.store.tooling.plugins.Store6MultiplatformSubsetConventionPlugin" + } + + register("store6ObjcSwiftDumpPlugin") { + id = "org.mobilenativefoundation.store.store6.swift-dump.objc" + implementationClass = "org.mobilenativefoundation.store.tooling.plugins.Store6ObjcSwiftDumpPlugin" + } + + register("store6SkieSwiftDumpPlugin") { + id = "org.mobilenativefoundation.store.store6.swift-dump.skie" + implementationClass = "org.mobilenativefoundation.store.tooling.plugins.Store6SkieSwiftDumpPlugin" + } + register("androidConventionPlugin") { id = "org.mobilenativefoundation.store.android" implementationClass = "org.mobilenativefoundation.store.tooling.plugins.AndroidConventionPlugin" } - - register("formattingConventionPlugin") { - id = "org.mobilenativefoundation.store.formatting" - implementationClass = "org.mobilenativefoundation.store.tooling.plugins.FormattingConventionPlugin" - } } } - -ktlint { - version = libs.versions.ktlint.get() - additionalEditorconfig.put("ktlint_standard_function-expression-body", "disabled") - additionalEditorconfig.put("ktlint_standard_function-signature", "disabled") - additionalEditorconfig.put("ktlint_standard_multiline-expression-wrapping", "disabled") - additionalEditorconfig.put("ktlint_standard_string-template-indent", "disabled") -} diff --git a/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/AndroidConventionPlugin.kt b/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/AndroidConventionPlugin.kt index 64bc94db5..7f105a3d9 100644 --- a/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/AndroidConventionPlugin.kt +++ b/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/AndroidConventionPlugin.kt @@ -7,59 +7,46 @@ import org.gradle.api.JavaVersion import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.configure -import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension class AndroidConventionPlugin : Plugin { override fun apply(project: Project) = with(project) { with(pluginManager) { + apply("org.jetbrains.kotlin.android") apply("com.android.library") apply("com.vanniktech.maven.publish") apply("org.jetbrains.dokka") apply("maven-publish") apply("org.jetbrains.kotlinx.binary-compatibility-validator") - apply("org.mobilenativefoundation.store.formatting") } + extensions.configure { - compileSdk = versionCatalog.androidCompileSdk.toInt() + + compileSdk = 34 defaultConfig { - minSdk = versionCatalog.androidMinSdk.toInt() + minSdk = 24 + targetSdk = 34 } - val targetSdkVersion = versionCatalog.androidTargetSdk.toInt() - lint { disable += "ComposableModifierFactory" disable += "ModifierFactoryExtensionFunction" disable += "ModifierFactoryReturnType" disable += "ModifierFactoryUnreferencedReceiver" - targetSdk = targetSdkVersion - } - - testOptions { - targetSdk = targetSdkVersion } compileOptions { - val jvmCompatVersion = JavaVersion.toVersion(versionCatalog.jvmCompatVersion) - sourceCompatibility = jvmCompatVersion - targetCompatibility = jvmCompatVersion + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } } - configureAndroidKotlin() + configureKotlin() configureDokka() configureMavenPublishing() } } -fun Project.configureAndroidKotlin() { - extensions.configure { - compilerOptions { - jvmTarget.set(JvmTarget.fromTarget(versionCatalog.jvmCompatVersion)) - } - } - configureJava() -} + + diff --git a/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/FormattingConventionPlugin.kt b/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/FormattingConventionPlugin.kt deleted file mode 100644 index 6a05f1e72..000000000 --- a/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/FormattingConventionPlugin.kt +++ /dev/null @@ -1,35 +0,0 @@ -package org.mobilenativefoundation.store.tooling.plugins - -import com.diffplug.gradle.spotless.SpotlessExtension -import org.gradle.api.Plugin -import org.gradle.api.Project -import org.gradle.api.artifacts.VersionCatalogsExtension -import org.gradle.kotlin.dsl.assign -import org.gradle.kotlin.dsl.configure -import org.gradle.kotlin.dsl.getByType -import org.jlleitschuh.gradle.ktlint.KtlintExtension - -class FormattingConventionPlugin : Plugin { - override fun apply(target: Project) { - target.pluginManager.apply("org.jlleitschuh.gradle.ktlint") - target.pluginManager.apply("com.diffplug.spotless") - target.extensions.configure { - kotlin { - target("src/**/*.kt") - } - } - val versionCatalog = target.extensions.getByType().named("libs") - target.extensions.configure { - version = versionCatalog.findVersion("ktlint").get().requiredVersion - additionalEditorconfig.put("ktlint_standard_function-expression-body", "disabled") - additionalEditorconfig.put("ktlint_standard_class-signature", "disabled") - additionalEditorconfig.put("ktlint_standard_spacing-between-declarations-with-comments", "disabled") - additionalEditorconfig.put("ktlint_standard_when-entry-bracing", "disabled") - additionalEditorconfig.put("ktlint_standard_blank-line-between-when-conditions", "disabled") - additionalEditorconfig.put("ktlint_standard_kdoc", "disabled") - additionalEditorconfig.put("ktlint_standard_max-line-length", "disabled") - additionalEditorconfig.put("ktlint_standard_chain-method-continuation", "disabled") - additionalEditorconfig.put("ktlint_standard_function-signature", "disabled") - } - } -} diff --git a/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/KotlinMultiplatformConventionPlugin.kt b/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/KotlinMultiplatformConventionPlugin.kt index 87c161aeb..02bac221c 100644 --- a/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/KotlinMultiplatformConventionPlugin.kt +++ b/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/KotlinMultiplatformConventionPlugin.kt @@ -2,59 +2,52 @@ package org.mobilenativefoundation.store.tooling.plugins -import co.touchlab.kmmbridge.KmmBridgeExtension -import com.android.build.api.dsl.KotlinMultiplatformAndroidLibraryTarget +import addGithubPackagesRepository +import co.touchlab.faktory.KmmBridgeExtension +import com.android.build.api.dsl.LibraryExtension import com.vanniktech.maven.publish.MavenPublishBaseExtension -import gitHubReleaseArtifacts import kotlinx.atomicfu.plugin.gradle.AtomicFUPluginExtension -import org.gradle.api.Action import org.gradle.api.JavaVersion import org.gradle.api.Plugin import org.gradle.api.Project -import org.gradle.api.artifacts.VersionCatalogsExtension import org.gradle.api.plugins.ExtensionAware import org.gradle.api.plugins.JavaPluginExtension import org.gradle.jvm.toolchain.JavaLanguageVersion import org.gradle.kotlin.dsl.configure -import org.gradle.kotlin.dsl.getByType +import org.gradle.kotlin.dsl.get import org.gradle.kotlin.dsl.withType import org.jetbrains.dokka.gradle.DokkaExtension import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl -import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import org.jetbrains.kotlin.gradle.dsl.KotlinJvmCompilerOptions +import org.jetbrains.kotlin.gradle.dsl.JvmDefaultMode import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension import org.jetbrains.kotlin.gradle.plugin.cocoapods.CocoapodsExtension +import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinAndroidTarget import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget +import org.jetbrains.kotlin.gradle.targets.jvm.KotlinJvmTarget class KotlinMultiplatformConventionPlugin : Plugin { override fun apply(project: Project) = with(project) { - val versionCatalog = extensions.getByType().named("libs") - val jvmToolchainVersion = versionCatalog.jvmToolchainVersion - // Single source of truth for the published version: gradle/libs.versions.toml (store). - // vanniktech maven-publish falls back to project.version when VERSION_NAME is absent. - version = versionCatalog.store with(pluginManager) { apply("org.jetbrains.kotlin.multiplatform") apply("org.jetbrains.kotlin.plugin.serialization") - apply("com.android.kotlin.multiplatform.library") + apply("com.android.library") apply("com.vanniktech.maven.publish") apply("org.jetbrains.dokka") - apply("co.touchlab.kmmbridge.github") + apply("co.touchlab.kmmbridge") apply("maven-publish") apply("org.jetbrains.kotlin.native.cocoapods") - apply("org.jetbrains.kotlinx.atomicfu") + apply("kotlinx-atomicfu") apply("org.jetbrains.kotlinx.binary-compatibility-validator") - apply("org.mobilenativefoundation.store.formatting") } + extensions.configure { + applyDefaultHierarchyTemplate() - context(this, this@with) { - configureAndroid() - } + androidTarget() jvm() @@ -65,58 +58,51 @@ class KotlinMultiplatformConventionPlugin : Plugin { linuxX64() js { - browser { - testTask { - useKarma { - useChromeHeadless() - } - // Karma uses Mocha under the hood for the test framework - useMocha { - timeout = "5s" - } - } - } - nodejs { - testTask { - useMocha { - timeout = "5s" - } - } - } + browser() + nodejs() } @OptIn(ExperimentalWasmDsl::class) wasmJs { - browser() nodejs() } - jvmToolchain(jvmToolchainVersion.toInt()) + jvmToolchain(11) + + targets.withType().configureEach { + compilerOptions { + jvmDefault.set(JvmDefaultMode.DISABLE) + } + } + + targets.withType().configureEach { + compilerOptions { + jvmDefault.set(JvmDefaultMode.DISABLE) + } + } targets.all { compilations.all { - compileTaskProvider.configure { - compilerOptions { freeCompilerArgs.add("-Xexpect-actual-classes") } + compilerOptions.configure { + freeCompilerArgs.add("-Xexpect-actual-classes") } } } targets.withType().configureEach { compilations.configureEach { - compileTaskProvider.configure { - compilerOptions { - freeCompilerArgs.add("-Xallocator=custom") - freeCompilerArgs.add("-Xadd-light-debug=enable") - - freeCompilerArgs.addAll( - "-opt-in=kotlin.RequiresOptIn", - "-opt-in=kotlin.time.ExperimentalTime", - "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", - "-opt-in=kotlinx.coroutines.FlowPreview", - "-opt-in=kotlinx.cinterop.ExperimentalForeignApi", - "-opt-in=kotlinx.cinterop.BetaInteropApi", - ) - } + compilerOptions.configure { + freeCompilerArgs.add("-Xallocator=custom") + freeCompilerArgs.add("-Xadd-light-debug=enable") + + freeCompilerArgs.addAll( + "-opt-in=kotlin.RequiresOptIn", + "-opt-in=kotlin.time.ExperimentalTime", + "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", + "-opt-in=kotlinx.coroutines.FlowPreview", + "-opt-in=kotlinx.cinterop.ExperimentalForeignApi", + "-opt-in=kotlinx.cinterop.BetaInteropApi", + ) } } } @@ -129,110 +115,157 @@ class KotlinMultiplatformConventionPlugin : Plugin { } } - sourceSets.getByName("commonTest") { dependencies { implementation(kotlin("test")) } } + sourceSets.getByName("commonTest") { + dependencies { + implementation(kotlin("test")) + } + } + + sourceSets.getByName("jvmTest") { + dependencies { + implementation(kotlin("test-junit")) + } + } - sourceSets.getByName("jvmTest") { dependencies { implementation(kotlin("test-junit")) } } + sourceSets.getByName("nativeMain") { + dependsOn(sourceSets.getByName("commonMain")) + } - configureCocoapods(project.versionCatalog.store) + configureCocoapods() } - configureMultiplatformKotlin() + configureKotlin() + configureAndroid() configureDokka() configureMavenPublishing() + addGithubPackagesRepository() configureKmmBridge() configureAtomicFu() } } -fun Project.configureMultiplatformKotlin() { - val jvmCompatVersion = versionCatalog.jvmCompatVersion - extensions.configure { - targets.configureEach { - compilations.configureEach { - compileTaskProvider.configure { - compilerOptions { - if (this is KotlinJvmCompilerOptions) { - jvmTarget.set(JvmTarget.fromTarget(jvmCompatVersion)) - } - } - } - } - } - } + +fun Project.configureKotlin() { configureJava() } fun Project.configureJava() { java { - toolchain { languageVersion.set(JavaLanguageVersion.of(versionCatalog.jvmToolchainVersion)) } - val jvmCompatVersion = JavaVersion.toVersion(versionCatalog.jvmCompatVersion) - sourceCompatibility = jvmCompatVersion - targetCompatibility = jvmCompatVersion + toolchain { + languageVersion.set(JavaLanguageVersion.of(11)) + } } } -context(ext: KotlinMultiplatformExtension, project: Project) -fun configureAndroid() { - ext.android { - namespace = "org.mobilenativefoundation.store.${project.name}" - - compileSdk = project.versionCatalog.androidCompileSdk.toInt() - minSdk = project.versionCatalog.androidMinSdk.toInt() - val targetSdkVersion = project.versionCatalog.androidTargetSdk.toInt() +fun Project.configureAndroid() { + android { + sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") + compileSdk = Versions.COMPILE_SDK + defaultConfig { + minSdk = Versions.MIN_SDK + targetSdk = Versions.TARGET_SDK + } lint { disable += "ComposableModifierFactory" disable += "ModifierFactoryExtensionFunction" disable += "ModifierFactoryReturnType" disable += "ModifierFactoryUnreferencedReceiver" - targetSdk = targetSdkVersion } - withHostTest { - targetSdk { - version = release(targetSdkVersion) - } - } - withDeviceTest { - targetSdk { - version = release(targetSdkVersion) - } - } - compilerOptions { - jvmTarget.set(JvmTarget.fromTarget(project.versionCatalog.jvmCompatVersion)) + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } } } -fun KotlinMultiplatformExtension.android(configure: Action): Unit = - (this as ExtensionAware).extensions.configure("android", configure) + +fun Project.android(action: LibraryExtension.() -> Unit) = extensions.configure(action) private fun Project.java(action: JavaPluginExtension.() -> Unit) = extensions.configure(action) -fun Project.configureMavenPublishing() = - extensions.configure { - publishToMavenCentral(automaticRelease = true) - signAllPublications() - } -fun Project.configureKmmBridge() = - extensions.configure { - gitHubReleaseArtifacts() - spm() - } +object Versions { + const val COMPILE_SDK = 34 + const val MIN_SDK = 24 + const val TARGET_SDK = 34 + const val STORE = "5.1.0-alpha10" +} + + +fun Project.configureMavenPublishing() = extensions.configure { + publishToMavenCentral(automaticRelease = true) + signAllPublications() +} -fun Project.configureAtomicFu() = extensions.configure { transformJvm = false } + +fun Project.configureKmmBridge() = extensions.configure { + mavenPublishArtifacts() + spm() +} + +fun Project.configureAtomicFu() = + extensions.configure { + transformJvm = false + transformJs = false + } fun Project.configureDokka() { + val moduleDocumentation = layout.projectDirectory.file("dokka/Module.md") + extensions.configure { + dokkaPublications.named("html") { + outputDirectory.set(layout.buildDirectory.dir("dokka/html")) + } dokkaSourceSets.configureEach { reportUndocumented.set(false) skipDeprecated.set(true) - jdkVersion.set(versionCatalog.jvmCompatVersion.toInt()) + jdkVersion.set(11) + externalDocumentationLinks.register("kotlinx-coroutines") { + url("https://kotlinlang.org/api/kotlinx.coroutines/") + packageListUrl("https://kotlinlang.org/api/kotlinx.coroutines/package-list") + } + if (project.name == "store6-mutations") { + externalDocumentationLinks.register("store6-core") { + url("https://store.mobilenativefoundation.org/reference/store6-core/") + packageListUrl.set( + rootProject + .project(":store6-core") + .layout.buildDirectory + .file("dokka/html/store6-core/package-list") + .map { it.asFile.toURI() }, + ) + } + } + } + + if (moduleDocumentation.asFile.isFile) { + dokkaSourceSets.matching { it.name == "commonMain" }.configureEach { + includes.from(moduleDocumentation) + } } } + + if (project.name == "store6-mutations") { + tasks.named("dokkaGeneratePublicationHtml").configure { + dependsOn(":store6-core:dokkaGeneratePublicationHtml") + } + } + tasks.register("dokkaHtml") { + group = "documentation" + description = "Generates Dokka HTML documentation." + dependsOn("dokkaGeneratePublicationHtml") + } +} + +fun Project.android(name: String) { + android { + namespace = "org.mobilenativefoundation.store.$name" + } } -fun KotlinMultiplatformExtension.configureCocoapods(storeVersion: String) { +fun KotlinMultiplatformExtension.configureCocoapods(){ (this as ExtensionAware).extensions.configure(CocoapodsExtension::class.java) { - version = storeVersion + version = Versions.STORE } } diff --git a/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/Store6Conventions.kt b/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/Store6Conventions.kt new file mode 100644 index 000000000..fcaeb2b74 --- /dev/null +++ b/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/Store6Conventions.kt @@ -0,0 +1,125 @@ +@file:Suppress("UnstableApiUsage") + +package org.mobilenativefoundation.store.tooling.plugins + +import com.android.build.api.dsl.LibraryExtension +import com.vanniktech.maven.publish.JavadocJar +import com.vanniktech.maven.publish.KotlinMultiplatform +import com.vanniktech.maven.publish.MavenPublishBaseExtension +import kotlinx.validation.ApiValidationExtension +import kotlinx.validation.ExperimentalBCVApi +import org.gradle.api.JavaVersion +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.get +import org.gradle.kotlin.dsl.withType +import org.jetbrains.kotlin.gradle.dsl.JvmDefaultMode +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinAndroidTarget +import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTargetWithSimulatorTests +import org.jetbrains.kotlin.gradle.targets.jvm.KotlinJvmTarget + +/** + * Conventions shared by every store6 library module (full and subset variants): + * KMP + android-library + vanniktech publishing + BCV(klib) + Dokka, explicitApi, + * default hierarchy, jvmToolchain(11), test deps, simulator-device wiring, android defaults. + * Subset modules declare their own targets AFTER this applies; androidTarget() is REQUIRED + * (android library config + "release"-variant publishing are unconditional here), and + * target spellings must exactly match Store6MultiplatformConventionPlugin's declarations. + */ +internal fun Project.configureStore6Module() { + with(pluginManager) { + apply("org.jetbrains.kotlin.multiplatform") + apply("com.android.library") + apply("com.vanniktech.maven.publish") + apply("org.jetbrains.dokka") + apply("org.jetbrains.kotlinx.binary-compatibility-validator") + } + + extensions.configure { + @OptIn(ExperimentalBCVApi::class) + klib { + enabled = true + } + } + + tasks.configureEach { + if (name.endsWith("ApiCheck")) { + mustRunAfter("${name.removeSuffix("Check")}Dump") + } + } + + extensions.configure { + explicitApi() + applyDefaultHierarchyTemplate() + jvmToolchain(11) + + targets.withType().configureEach { + compilerOptions { + jvmDefault.set(JvmDefaultMode.DISABLE) + } + } + + targets.withType().configureEach { + compilerOptions { + jvmDefault.set(JvmDefaultMode.DISABLE) + } + } + + providers.gradleProperty("store6.iosSimulatorDevice").orNull?.let { device -> + targets.withType().configureEach { + testRuns.configureEach { + deviceId = device + } + } + } + + // matching{} (not getByName): in the subset variant these source sets appear only + // once the module declares its targets, after this plugin has applied. + sourceSets.matching { it.name == "commonTest" }.configureEach { + dependencies { + implementation(kotlin("test")) + } + } + + sourceSets.matching { it.name == "jvmTest" }.configureEach { + dependencies { + implementation(kotlin("test-junit")) + } + } + } + + extensions.configure { + sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") + // 36 (was 34): Room 2.8.x AAR metadata forces a modern compileSdk; kept uniform + // across store6 modules. v5 modules intentionally stay on 34 (zero v5 change). + compileSdk = 36 + + defaultConfig { + minSdk = 24 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + } + + configureDokka() + + val signingKeyPresent = providers.gradleProperty("signingInMemoryKey").isPresent + extensions.configure { + configure( + KotlinMultiplatform( + javadocJar = JavadocJar.Empty(), + sourcesJar = true, + androidVariantsToPublish = listOf("release"), + ), + ) + publishToMavenCentral(automaticRelease = true) + + if (signingKeyPresent) { + signAllPublications() + } + } +} diff --git a/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/Store6MultiplatformConventionPlugin.kt b/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/Store6MultiplatformConventionPlugin.kt new file mode 100644 index 000000000..08f629dcb --- /dev/null +++ b/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/Store6MultiplatformConventionPlugin.kt @@ -0,0 +1,33 @@ +package org.mobilenativefoundation.store.tooling.plugins + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension + +/** Full-parity store6 module: shared conventions plus the canonical 12 targets. */ +class Store6MultiplatformConventionPlugin : Plugin { + override fun apply(project: Project) { + project.configureStore6Module() + project.extensions.configure { + androidTarget() + jvm() + iosX64() + iosArm64() + iosSimulatorArm64() + macosArm64() + watchosArm64() + tvosArm64() + linuxX64() + mingwX64() + js { + nodejs() + } + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + nodejs() + } + } + } +} diff --git a/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/Store6MultiplatformSubsetConventionPlugin.kt b/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/Store6MultiplatformSubsetConventionPlugin.kt new file mode 100644 index 000000000..7fcfcea1a --- /dev/null +++ b/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/Store6MultiplatformSubsetConventionPlugin.kt @@ -0,0 +1,15 @@ +package org.mobilenativefoundation.store.tooling.plugins + +import org.gradle.api.Plugin +import org.gradle.api.Project + +/** + * Subset variant: identical store6 conventions, zero targets — the module's kotlin {} + * block declares a subset of the canonical 12 (androidTarget() required, spellings must + * match the full plugin). First consumer: store6-room (Room 2.8.x has no js/wasmJs/mingwX64). + */ +class Store6MultiplatformSubsetConventionPlugin : Plugin { + override fun apply(project: Project) { + project.configureStore6Module() + } +} diff --git a/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/SwiftDumpTasksPlugin.kt b/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/SwiftDumpTasksPlugin.kt new file mode 100644 index 000000000..11cc1ed8a --- /dev/null +++ b/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/SwiftDumpTasksPlugin.kt @@ -0,0 +1,408 @@ +// DRAFT — full replacement for +// tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/SwiftDumpTasksPlugin.kt +// +// Config mechanism: a project extension (`store6SwiftDump`) with core-valued conventions. +// Why an extension and not gradle properties: +// 1. The existing plugins are convention plugins configured per applying project; the surface +// identity (framework name, committed directory) is per-project build topology, not an +// invocation-time switch. Gradle properties (-P / gradle.properties) are global to the build +// invocation and would let one lane's value leak into another; an extension is scoped to +// exactly one applying project. +// 2. Property-based config cannot be expressed in the applying build.gradle.kts next to the +// framework declaration it must stay in sync with; the extension keeps +// `baseName = "Store6Mutations"` and `frameworkName.set("Store6Mutations")` in one file +// under one review. +// 3. Conventions (`convention(...)`) give the byte-equivalence guarantee mechanically: an +// unconfigured applying project (the two existing core lanes) resolves to exactly the +// previous hardcoded strings, so the core build files need zero edits and +// `coreDefaults_remainByteEquivalent` pins that. +// Task names are UNCHANGED for every applying project: generateSwiftDump, refreshSwiftDump, +// checkSwiftDump, validateSkieSwiftLayout. Group is UNCHANGED: "Store6 verification". +// All previously hardcoded strings are now derived from the extension; with defaults the derived +// values are byte-identical to the strings at e3c9e6d (see the hardcode inventory in NOTES.md). + +package org.mobilenativefoundation.store.tooling.plugins + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.Sync +import org.gradle.api.tasks.TaskAction +import org.gradle.work.DisableCachingByDefault +import java.io.File + +private const val SKIE_GENERATED_SWIFT_LAYOUT = + "skie/binaries/debugFramework/DEBUG/iosArm64/swift/generated" + +private const val SWIFT_DUMP_EXTENSION_NAME = "store6SwiftDump" + +private const val VERIFICATION_GROUP = "Store6 verification" + +private val volatileVersionLine = + Regex("""(?i)^\s*(//|/\*|\*)?\s*(compiler|kotlin|skie)\s+version\b.*""") + +/** + * Per-project configuration for the Swift dump lanes. + * + * Every property has a core-valued convention so the two existing core lanes + * (store6-swift-dumps/objc and store6-swift-dumps/skie) apply the plugins without configuration + * and keep byte-identical task wiring. New lanes (store6-swift-dumps/mutations-objc and + * mutations-skie) override all identity properties explicitly. + */ +abstract class SwiftDumpExtension { + /** Reviewed surface module name, e.g. "store6-core" or "store6-mutations". */ + abstract val surfaceName: Property + + /** Framework base name, e.g. "Store6Core", "Store6CoreSkie", "Store6Mutations". */ + abstract val frameworkName: Property + + /** Staged header file name. Convention: ".h". */ + abstract val outputHeaderName: Property + + /** Staged combined Swift file name (SKIE lanes only). Convention: ".swift". */ + abstract val outputSwiftName: Property + + /** + * Committed dump directory, relative to the root project, + * e.g. "store6-core/api/swift/objc". Convention: "/api/swift/". + */ + abstract val committedDumpPath: Property +} + +@CacheableTask +abstract class GenerateObjcSwiftDumpTask : DefaultTask() { + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val linkedHeader: RegularFileProperty + + @get:Input + abstract val outputHeaderName: Property + + @get:OutputDirectory + abstract val outputDirectory: DirectoryProperty + + @TaskAction + fun generate() { + val header = linkedHeader.get().asFile + if (!header.isFile) { + throw GradleException("Expected Obj-C export header at ${header.path}") + } + + val output = outputDirectory.get().asFile + recreateDirectory(output) + output.resolve(outputHeaderName.get()).writeText(sanitized(header.readText())) + } +} + +@DisableCachingByDefault( + because = "SKIE does not expose the generated Swift directory as a declared task output", +) +abstract class GenerateSkieSwiftDumpTask : DefaultTask() { + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val linkedHeader: RegularFileProperty + + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val generatedSwiftRoot: DirectoryProperty + + @get:Input + abstract val supportedLayout: Property + + @get:Input + abstract val outputHeaderName: Property + + @get:Input + abstract val outputSwiftName: Property + + @get:OutputDirectory + abstract val outputDirectory: DirectoryProperty + + @TaskAction + fun generate() { + val header = linkedHeader.get().asFile + if (!header.isFile) { + throw GradleException("Expected SKIE-processed header at ${header.path}") + } + + val swiftRoot = generatedSwiftRoot.get().asFile + if (!swiftRoot.isDirectory) { + throw unsupportedSkieLayout(swiftRoot, supportedLayout.get()) + } + + val swiftFiles = swiftRoot.walkTopDown() + .filter { it.isFile && it.extension == "swift" } + .sortedBy { it.relativeTo(swiftRoot).invariantSeparatorsPath() } + .toList() + if (swiftFiles.isEmpty()) { + throw unsupportedSkieLayout(swiftRoot, supportedLayout.get()) + } + + val output = outputDirectory.get().asFile + recreateDirectory(output) + output.resolve(outputHeaderName.get()).writeText(sanitized(header.readText())) + val combinedSwift = + buildString { + swiftFiles.forEach { file -> + val relativePath = file.relativeTo(swiftRoot).invariantSeparatorsPath() + appendLine("// FILE: $relativePath") + append(sanitized(file.readText())) + appendLine() + } + }.trimEnd() + "\n" + output.resolve(outputSwiftName.get()).writeText(combinedSwift) + } +} + +@DisableCachingByDefault(because = "Layout validation must run after every SKIE framework link") +abstract class ValidateSkieSwiftLayoutTask : DefaultTask() { + @get:Internal + abstract val generatedSwiftRoot: DirectoryProperty + + @get:Input + abstract val supportedLayout: Property + + @TaskAction + fun validate() { + val swiftRoot = generatedSwiftRoot.get().asFile + val containsSwiftSource = swiftRoot.isDirectory && swiftRoot.walkTopDown().any { + it.isFile && it.extension == "swift" + } + if (!containsSwiftSource) { + throw unsupportedSkieLayout(swiftRoot, supportedLayout.get()) + } + } +} + +@DisableCachingByDefault(because = "Verification task has no outputs") +abstract class CheckSwiftDumpTask : DefaultTask() { + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val generatedFiles: ConfigurableFileCollection + + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val committedFiles: ConfigurableFileCollection + + @get:Internal + abstract val generatedDirectory: DirectoryProperty + + @get:Internal + abstract val committedDirectory: DirectoryProperty + + @get:Input + abstract val failureMessage: Property + + @TaskAction + fun check() { + val generated = snapshot(generatedDirectory.get().asFile) + val committed = snapshot(committedDirectory.get().asFile) + if (generated.isEmpty()) { + throw GradleException("No generated Swift dump files are available to check.") + } + + val missing = (generated.keys - committed.keys).sorted() + val stale = (committed.keys - generated.keys).sorted() + val changed = (generated.keys intersect committed.keys) + .filterNot { generated.getValue(it).contentEquals(committed.getValue(it)) } + .sorted() + + if (missing.isNotEmpty() || stale.isNotEmpty() || changed.isNotEmpty()) { + throw GradleException( + buildString { + appendLine(failureMessage.get()) + appendDifference("Missing committed files", missing) + appendDifference("Stale committed files", stale) + appendDifference("Changed files", changed) + }.trimEnd(), + ) + } + } +} + +class Store6ObjcSwiftDumpPlugin : Plugin { + override fun apply(project: Project) { + with(project) { + val dump = extensions.create(SWIFT_DUMP_EXTENSION_NAME, SwiftDumpExtension::class.java) + dump.surfaceName.convention("store6-core") + dump.frameworkName.convention("Store6Core") + dump.outputHeaderName.convention(dump.frameworkName.map { "$it.h" }) + dump.outputSwiftName.convention(dump.frameworkName.map { "$it.swift" }) + dump.committedDumpPath.convention(dump.surfaceName.map { "$it/api/swift/objc" }) + + val stagedDirectory = layout.buildDirectory.dir("swift-dump") + val committedDumpDirectory = dump.committedDumpPath.map { path -> + rootProject.layout.projectDirectory.dir(path) + } + val generate = tasks.register("generateSwiftDump", GenerateObjcSwiftDumpTask::class.java) { + group = VERIFICATION_GROUP + description = "Generates the sanitized Obj-C export dump for ${dump.surfaceName.get()}." + dependsOn("linkDebugFrameworkIosArm64") + linkedHeader.set( + dump.frameworkName.flatMap { framework -> + layout.buildDirectory.file( + "bin/iosArm64/debugFramework/${framework}.framework/Headers/${framework}.h", + ) + }, + ) + outputHeaderName.set(dump.outputHeaderName) + outputDirectory.set(stagedDirectory) + } + + val refresh = tasks.register("refreshSwiftDump", Sync::class.java) { + group = VERIFICATION_GROUP + description = "Refreshes the committed Obj-C export dump for ${dump.surfaceName.get()}." + dependsOn(generate) + doNotTrackState("Refresh must always reconcile stale files in the committed dump.") + from(stagedDirectory) + into(committedDumpDirectory) + includeEmptyDirs = false + } + + tasks.register("checkSwiftDump", CheckSwiftDumpTask::class.java) { + group = VERIFICATION_GROUP + description = "Checks the committed Obj-C export dump for ${dump.surfaceName.get()}." + dependsOn(generate) + mustRunAfter(refresh) + generatedFiles.from(stagedDirectory) + committedFiles.from(committedDumpDirectory) + generatedDirectory.set(stagedDirectory) + committedDirectory.set(committedDumpDirectory) + failureMessage.set( + dump.surfaceName.zip(dump.committedDumpPath) { surface, committedPath -> + "Obj-C export dump for $surface has drifted from $committedPath. " + + "Run ./gradlew refreshSwiftDumps and commit the result." + }, + ) + } + } + } +} + +class Store6SkieSwiftDumpPlugin : Plugin { + override fun apply(project: Project) { + with(project) { + val dump = extensions.create(SWIFT_DUMP_EXTENSION_NAME, SwiftDumpExtension::class.java) + dump.surfaceName.convention("store6-core") + dump.frameworkName.convention("Store6CoreSkie") + dump.outputHeaderName.convention(dump.frameworkName.map { "$it.h" }) + dump.outputSwiftName.convention(dump.frameworkName.map { "$it.swift" }) + dump.committedDumpPath.convention(dump.surfaceName.map { "$it/api/swift/skie" }) + + val stagedDirectory = layout.buildDirectory.dir("swift-dump") + val committedDumpDirectory = dump.committedDumpPath.map { path -> + rootProject.layout.projectDirectory.dir(path) + } + val generatedSwiftDirectory = layout.buildDirectory.dir(SKIE_GENERATED_SWIFT_LAYOUT) + val validateLayout = tasks.register( + "validateSkieSwiftLayout", + ValidateSkieSwiftLayoutTask::class.java, + ) { + group = VERIFICATION_GROUP + description = "Validates the pinned SKIE-generated Swift layout for ${dump.surfaceName.get()}." + dependsOn("linkDebugFrameworkIosArm64") + generatedSwiftRoot.set(generatedSwiftDirectory) + supportedLayout.set(SKIE_GENERATED_SWIFT_LAYOUT) + } + val generate = tasks.register("generateSwiftDump", GenerateSkieSwiftDumpTask::class.java) { + group = VERIFICATION_GROUP + description = "Generates the sanitized SKIE dump for ${dump.surfaceName.get()}." + dependsOn(validateLayout) + doNotTrackState( + "SKIE does not expose the generated Swift directory as a declared task output.", + ) + linkedHeader.set( + dump.frameworkName.flatMap { framework -> + layout.buildDirectory.file( + "bin/iosArm64/debugFramework/${framework}.framework/Headers/${framework}.h", + ) + }, + ) + generatedSwiftRoot.set(generatedSwiftDirectory) + supportedLayout.set(SKIE_GENERATED_SWIFT_LAYOUT) + outputHeaderName.set(dump.outputHeaderName) + outputSwiftName.set(dump.outputSwiftName) + outputDirectory.set(stagedDirectory) + } + + val refresh = tasks.register("refreshSwiftDump", Sync::class.java) { + group = VERIFICATION_GROUP + description = "Refreshes the committed SKIE dump for ${dump.surfaceName.get()}." + dependsOn(generate) + doNotTrackState("Refresh must always reconcile stale files in the committed dump.") + from(stagedDirectory) + into(committedDumpDirectory) + includeEmptyDirs = false + } + + tasks.register("checkSwiftDump", CheckSwiftDumpTask::class.java) { + group = VERIFICATION_GROUP + description = "Checks the committed SKIE dump for ${dump.surfaceName.get()}." + dependsOn(generate) + mustRunAfter(refresh) + generatedFiles.from(stagedDirectory) + committedFiles.from(committedDumpDirectory) + generatedDirectory.set(stagedDirectory) + committedDirectory.set(committedDumpDirectory) + failureMessage.set( + dump.surfaceName.zip(dump.committedDumpPath) { surface, committedPath -> + "SKIE dump for $surface has drifted from $committedPath. " + + "Run ./gradlew refreshSwiftDumps and commit the result." + }, + ) + } + } + } +} + +private fun sanitized(raw: String): String = + raw.lineSequence() + .filterNot { volatileVersionLine.containsMatchIn(it) } + .joinToString("\n") + .trimEnd() + "\n" + +private fun unsupportedSkieLayout(swiftRoot: File, supportedLayout: String): GradleException = + GradleException( + "SKIE-generated Swift output is unavailable at ${swiftRoot.path}. " + + "The pinned SKIE 0.10.13 layout '$supportedLayout' was not produced; " + + "no supported SKIE output API is available.", + ) + +private fun recreateDirectory(directory: File) { + if (directory.exists() && !directory.deleteRecursively()) { + throw GradleException("Could not clear Swift dump staging directory at ${directory.path}") + } + if (!directory.mkdirs() && !directory.isDirectory) { + throw GradleException("Could not create Swift dump staging directory at ${directory.path}") + } +} + +private fun snapshot(directory: File): Map { + if (!directory.isDirectory) return emptyMap() + + return directory.walkTopDown() + .filter(File::isFile) + .associate { file -> file.relativeTo(directory).invariantSeparatorsPath() to file.readBytes() } +} + +private fun StringBuilder.appendDifference(label: String, paths: List) { + if (paths.isNotEmpty()) { + appendLine("$label: ${paths.joinToString()}") + } +} + +private fun File.invariantSeparatorsPath(): String = path.replace('\\', '/') diff --git a/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/VersionCatalogExt.kt b/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/VersionCatalogExt.kt deleted file mode 100644 index bf2c0f162..000000000 --- a/tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/VersionCatalogExt.kt +++ /dev/null @@ -1,26 +0,0 @@ -package org.mobilenativefoundation.store.tooling.plugins - -import org.gradle.api.Project -import org.gradle.api.artifacts.VersionCatalog -import org.gradle.api.artifacts.VersionCatalogsExtension - -val Project.versionCatalog: VersionCatalog - get() = extensions.getByType(VersionCatalogsExtension::class.java).named("libs") - -val VersionCatalog.jvmToolchainVersion: String - get() = findVersion("jvmToolchain").get().requiredVersion - -val VersionCatalog.jvmCompatVersion: String - get() = findVersion("jvmCompat").get().requiredVersion - -val VersionCatalog.store: String - get() = findVersion("store").get().requiredVersion - -val VersionCatalog.androidCompileSdk: String - get() = findVersion("androidCompileSdk").get().requiredVersion - -val VersionCatalog.androidMinSdk: String - get() = findVersion("androidMinSdk").get().requiredVersion - -val VersionCatalog.androidTargetSdk: String - get() = findVersion("androidTargetSdk").get().requiredVersion diff --git a/tooling/plugins/src/test/kotlin/org/mobilenativefoundation/store/tooling/plugins/SwiftDumpTasksPluginTest.kt b/tooling/plugins/src/test/kotlin/org/mobilenativefoundation/store/tooling/plugins/SwiftDumpTasksPluginTest.kt new file mode 100644 index 000000000..ef291c329 --- /dev/null +++ b/tooling/plugins/src/test/kotlin/org/mobilenativefoundation/store/tooling/plugins/SwiftDumpTasksPluginTest.kt @@ -0,0 +1,306 @@ +// DRAFT — new file at +// tooling/plugins/src/test/kotlin/org/mobilenativefoundation/store/tooling/plugins/SwiftDumpTasksPluginTest.kt +// +// Mechanism: ProjectBuilder (org.gradle.testfixtures), NOT GradleRunner. Rationale: +// - T4.9 requires the reds to "inspect configured task inputs/outputs". ProjectBuilder gives +// direct typed access to task properties (linkedHeader, outputHeaderName, committedDirectory, +// failureMessage, description) without linking any iOS framework. GradleRunner is black-box: +// it could only observe task EXECUTION, which requires a Kotlin/Native toolchain and a real +// linked framework per test — unusable as a fast plugin red. +// - ProjectBuilder ships inside gradleApi(), which the `kotlin-dsl` plugin already puts on the +// compile classpath. gradleTestKit() is still added per the plan so the lane can grow +// functional (GradleRunner) coverage later without another build change. +// - String dependsOn("linkDebugFrameworkIosArm64") is only resolved at task-graph time, so +// realizing the dump tasks via getByName in a ProjectBuilder project is safe with no KMP +// plugin applied. +// +// FIRST-RED STATUS (against the plugin at e3c9e6d): this file does not compile — +// `SwiftDumpExtension` does not exist and the plugins expose no configuration door. The archived +// first-red evidence is therefore the compiler transcript of +// ./gradlew -p tooling :plugins:test --tests '...SwiftDumpTasksPluginTest' --stacktrace +// (T4.9 explicitly allows "XML/compiler transcript"). All three named reds share that transcript; +// coreDefaults_remainByteEquivalent then stays as the permanent byte-equivalence pin once green. +// +// Required test dependencies (see tooling-plugins-build.gradle.kts.patch): +// testImplementation(kotlin("test", embeddedKotlinVersion)) +// testImplementation(libs.junit) // JUnit 4 runner for kotlin-test's default JVM variant +// testImplementation(gradleTestKit()) + +package org.mobilenativefoundation.store.tooling.plugins + +import org.gradle.api.Project +import org.gradle.api.tasks.Sync +import org.gradle.testfixtures.ProjectBuilder +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals + +class SwiftDumpTasksPluginTest { + + // --------------------------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------------------------- + + private fun rootProject(): Project = ProjectBuilder.builder().withName("Store5").build() + + private fun childProject(root: Project, name: String): Project = + ProjectBuilder.builder().withName(name).withParent(root).build() + + private fun Project.expectedLinkedHeader(frameworkName: String): File = + layout.buildDirectory + .file("bin/iosArm64/debugFramework/$frameworkName.framework/Headers/$frameworkName.h") + .get() + .asFile + + private fun Project.expectedStagedDirectory(): File = + layout.buildDirectory.dir("swift-dump").get().asFile + + private fun Project.expectedCommittedDirectory(relativePath: String): File = + rootProject.layout.projectDirectory.dir(relativePath).asFile + + // --------------------------------------------------------------------------------------------- + // Red 1 — ObjC plugin honors per-project configuration + // --------------------------------------------------------------------------------------------- + + @Test + fun objcProject_usesConfiguredFrameworkOutputAndCommittedDirectory() { + val root = rootProject() + val project = childProject(root, "store6-swift-dumps-mutations-objc") + project.plugins.apply(Store6ObjcSwiftDumpPlugin::class.java) + project.extensions.getByType(SwiftDumpExtension::class.java).apply { + surfaceName.set("store6-mutations") + frameworkName.set("Store6Mutations") + outputHeaderName.set("Store6Mutations.h") + committedDumpPath.set("store6-mutations/api/swift/objc") + } + + val generate = project.tasks.getByName("generateSwiftDump") as GenerateObjcSwiftDumpTask + assertEquals("Store6 verification", generate.group) + assertEquals( + "Generates the sanitized Obj-C export dump for store6-mutations.", + generate.description, + ) + assertEquals( + project.expectedLinkedHeader("Store6Mutations"), + generate.linkedHeader.get().asFile, + ) + assertEquals("Store6Mutations.h", generate.outputHeaderName.get()) + assertEquals(project.expectedStagedDirectory(), generate.outputDirectory.get().asFile) + + val refresh = project.tasks.getByName("refreshSwiftDump") as Sync + assertEquals( + "Refreshes the committed Obj-C export dump for store6-mutations.", + refresh.description, + ) + assertEquals( + project.expectedCommittedDirectory("store6-mutations/api/swift/objc"), + refresh.destinationDir, + ) + + val check = project.tasks.getByName("checkSwiftDump") as CheckSwiftDumpTask + assertEquals( + "Checks the committed Obj-C export dump for store6-mutations.", + check.description, + ) + assertEquals(project.expectedStagedDirectory(), check.generatedDirectory.get().asFile) + assertEquals( + project.expectedCommittedDirectory("store6-mutations/api/swift/objc"), + check.committedDirectory.get().asFile, + ) + assertEquals( + "Obj-C export dump for store6-mutations has drifted from " + + "store6-mutations/api/swift/objc. " + + "Run ./gradlew refreshSwiftDumps and commit the result.", + check.failureMessage.get(), + ) + } + + // --------------------------------------------------------------------------------------------- + // Red 2 — SKIE plugin honors per-project configuration (header AND combined-Swift outputs) + // --------------------------------------------------------------------------------------------- + + @Test + fun skieProject_usesConfiguredFrameworkOutputsAndCommittedDirectory() { + val root = rootProject() + val project = childProject(root, "store6-swift-dumps-mutations-skie") + project.plugins.apply(Store6SkieSwiftDumpPlugin::class.java) + project.extensions.getByType(SwiftDumpExtension::class.java).apply { + surfaceName.set("store6-mutations") + frameworkName.set("Store6MutationsSkie") + outputHeaderName.set("Store6MutationsSkie.h") + outputSwiftName.set("Store6MutationsSkie.swift") + committedDumpPath.set("store6-mutations/api/swift/skie") + } + + val validate = project.tasks.getByName("validateSkieSwiftLayout") as ValidateSkieSwiftLayoutTask + assertEquals( + "Validates the pinned SKIE-generated Swift layout for store6-mutations.", + validate.description, + ) + assertEquals( + "skie/binaries/debugFramework/DEBUG/iosArm64/swift/generated", + validate.supportedLayout.get(), + ) + + val generate = project.tasks.getByName("generateSwiftDump") as GenerateSkieSwiftDumpTask + assertEquals("Store6 verification", generate.group) + assertEquals( + "Generates the sanitized SKIE dump for store6-mutations.", + generate.description, + ) + assertEquals( + project.expectedLinkedHeader("Store6MutationsSkie"), + generate.linkedHeader.get().asFile, + ) + assertEquals("Store6MutationsSkie.h", generate.outputHeaderName.get()) + assertEquals("Store6MutationsSkie.swift", generate.outputSwiftName.get()) + assertEquals(project.expectedStagedDirectory(), generate.outputDirectory.get().asFile) + + val refresh = project.tasks.getByName("refreshSwiftDump") as Sync + assertEquals( + "Refreshes the committed SKIE dump for store6-mutations.", + refresh.description, + ) + assertEquals( + project.expectedCommittedDirectory("store6-mutations/api/swift/skie"), + refresh.destinationDir, + ) + + val check = project.tasks.getByName("checkSwiftDump") as CheckSwiftDumpTask + assertEquals( + "Checks the committed SKIE dump for store6-mutations.", + check.description, + ) + assertEquals( + project.expectedCommittedDirectory("store6-mutations/api/swift/skie"), + check.committedDirectory.get().asFile, + ) + assertEquals( + "SKIE dump for store6-mutations has drifted from " + + "store6-mutations/api/swift/skie. " + + "Run ./gradlew refreshSwiftDumps and commit the result.", + check.failureMessage.get(), + ) + } + + // --------------------------------------------------------------------------------------------- + // Red 3 — unconfigured (core) projects keep EXACT pre-parameterization wiring + // Every literal below is transcribed from SwiftDumpTasksPlugin.kt at e3c9e6d; do not "sync" + // these with the plugin source when editing the plugin — they are the byte-equivalence pin. + // --------------------------------------------------------------------------------------------- + + @Test + fun coreDefaults_remainByteEquivalent() { + val root = rootProject() + + // --- ObjC lane, no configuration (mirrors store6-swift-dumps/objc) --- + val objc = childProject(root, "store6-swift-dumps-objc") + objc.plugins.apply(Store6ObjcSwiftDumpPlugin::class.java) + + val objcGenerate = objc.tasks.getByName("generateSwiftDump") as GenerateObjcSwiftDumpTask + assertEquals("Store6 verification", objcGenerate.group) + assertEquals( + "Generates the sanitized Obj-C export dump for store6-core.", + objcGenerate.description, + ) + assertEquals(objc.expectedLinkedHeader("Store6Core"), objcGenerate.linkedHeader.get().asFile) + assertEquals("Store6Core.h", objcGenerate.outputHeaderName.get()) + assertEquals(objc.expectedStagedDirectory(), objcGenerate.outputDirectory.get().asFile) + + val objcRefresh = objc.tasks.getByName("refreshSwiftDump") as Sync + assertEquals("Store6 verification", objcRefresh.group) + assertEquals( + "Refreshes the committed Obj-C export dump for store6-core.", + objcRefresh.description, + ) + assertEquals( + objc.expectedCommittedDirectory("store6-core/api/swift/objc"), + objcRefresh.destinationDir, + ) + + val objcCheck = objc.tasks.getByName("checkSwiftDump") as CheckSwiftDumpTask + assertEquals("Store6 verification", objcCheck.group) + assertEquals( + "Checks the committed Obj-C export dump for store6-core.", + objcCheck.description, + ) + assertEquals(objc.expectedStagedDirectory(), objcCheck.generatedDirectory.get().asFile) + assertEquals( + objc.expectedCommittedDirectory("store6-core/api/swift/objc"), + objcCheck.committedDirectory.get().asFile, + ) + assertEquals( + "Obj-C export dump for store6-core has drifted from store6-core/api/swift/objc. " + + "Run ./gradlew refreshSwiftDumps and commit the result.", + objcCheck.failureMessage.get(), + ) + + // --- SKIE lane, no configuration (mirrors store6-swift-dumps/skie) --- + val skie = childProject(root, "store6-swift-dumps-skie") + skie.plugins.apply(Store6SkieSwiftDumpPlugin::class.java) + + val skieValidate = skie.tasks.getByName("validateSkieSwiftLayout") as ValidateSkieSwiftLayoutTask + assertEquals("Store6 verification", skieValidate.group) + assertEquals( + "Validates the pinned SKIE-generated Swift layout for store6-core.", + skieValidate.description, + ) + assertEquals( + "skie/binaries/debugFramework/DEBUG/iosArm64/swift/generated", + skieValidate.supportedLayout.get(), + ) + assertEquals( + skie.layout.buildDirectory + .dir("skie/binaries/debugFramework/DEBUG/iosArm64/swift/generated") + .get() + .asFile, + skieValidate.generatedSwiftRoot.get().asFile, + ) + + val skieGenerate = skie.tasks.getByName("generateSwiftDump") as GenerateSkieSwiftDumpTask + assertEquals("Store6 verification", skieGenerate.group) + assertEquals( + "Generates the sanitized SKIE dump for store6-core.", + skieGenerate.description, + ) + assertEquals( + skie.expectedLinkedHeader("Store6CoreSkie"), + skieGenerate.linkedHeader.get().asFile, + ) + assertEquals("Store6CoreSkie.h", skieGenerate.outputHeaderName.get()) + assertEquals("Store6CoreSkie.swift", skieGenerate.outputSwiftName.get()) + assertEquals( + "skie/binaries/debugFramework/DEBUG/iosArm64/swift/generated", + skieGenerate.supportedLayout.get(), + ) + assertEquals(skie.expectedStagedDirectory(), skieGenerate.outputDirectory.get().asFile) + + val skieRefresh = skie.tasks.getByName("refreshSwiftDump") as Sync + assertEquals("Store6 verification", skieRefresh.group) + assertEquals( + "Refreshes the committed SKIE dump for store6-core.", + skieRefresh.description, + ) + assertEquals( + skie.expectedCommittedDirectory("store6-core/api/swift/skie"), + skieRefresh.destinationDir, + ) + + val skieCheck = skie.tasks.getByName("checkSwiftDump") as CheckSwiftDumpTask + assertEquals("Store6 verification", skieCheck.group) + assertEquals( + "Checks the committed SKIE dump for store6-core.", + skieCheck.description, + ) + assertEquals(skie.expectedStagedDirectory(), skieCheck.generatedDirectory.get().asFile) + assertEquals( + skie.expectedCommittedDirectory("store6-core/api/swift/skie"), + skieCheck.committedDirectory.get().asFile, + ) + assertEquals( + "SKIE dump for store6-core has drifted from store6-core/api/swift/skie. " + + "Run ./gradlew refreshSwiftDumps and commit the result.", + skieCheck.failureMessage.get(), + ) + } +} From 5512c457ce312efa4440db59148505ec525abd59 Mon Sep 17 00:00:00 2001 From: Matt Ramotar Date: Sun, 16 Aug 2026 17:32:14 -0400 Subject: [PATCH 2/2] Adopt Agent Plugins v1 package layout. Root plugin.json is the portable manifest. Claude marketplace and .claude/skills paths are client-specific and block other clients. Co-authored-by: Cursor --- .claude-plugin/marketplace.json | 16 ---------------- AGENTS.md | 4 ++-- plugins/internal/documentation/README.md | 8 ++++++++ plugins/internal/documentation/plugin.json | 17 +++++++++++++++++ .../skills/code-documentation/SKILL.md | 0 .../references/evidence-and-verification.md | 0 .../references/languages/go.md | 0 .../references/languages/java.md | 0 .../references/languages/kotlin.md | 0 .../references/languages/python.md | 0 .../references/languages/rust.md | 0 .../languages/typescript-javascript.md | 0 .../surfaces/implementation-documentation.md | 0 .../references/surfaces/inline-documentation.md | 0 .../surfaces/interface-documentation.md | 0 .../references/surfaces/package-readme.md | 0 .../references/surfaces/repository-readme.md | 0 .../skills/documentation-discipline/SKILL.md | 0 .../references/discipline-rules.md | 0 plugins/store/README.md | 6 ++++-- plugins/store/{.claude-plugin => }/plugin.json | 7 ++++--- 21 files changed, 35 insertions(+), 23 deletions(-) delete mode 100644 .claude-plugin/marketplace.json create mode 100644 plugins/internal/documentation/README.md create mode 100644 plugins/internal/documentation/plugin.json rename {.claude => plugins/internal/documentation}/skills/code-documentation/SKILL.md (100%) rename {.claude => plugins/internal/documentation}/skills/code-documentation/references/evidence-and-verification.md (100%) rename {.claude => plugins/internal/documentation}/skills/code-documentation/references/languages/go.md (100%) rename {.claude => plugins/internal/documentation}/skills/code-documentation/references/languages/java.md (100%) rename {.claude => plugins/internal/documentation}/skills/code-documentation/references/languages/kotlin.md (100%) rename {.claude => plugins/internal/documentation}/skills/code-documentation/references/languages/python.md (100%) rename {.claude => plugins/internal/documentation}/skills/code-documentation/references/languages/rust.md (100%) rename {.claude => plugins/internal/documentation}/skills/code-documentation/references/languages/typescript-javascript.md (100%) rename {.claude => plugins/internal/documentation}/skills/code-documentation/references/surfaces/implementation-documentation.md (100%) rename {.claude => plugins/internal/documentation}/skills/code-documentation/references/surfaces/inline-documentation.md (100%) rename {.claude => plugins/internal/documentation}/skills/code-documentation/references/surfaces/interface-documentation.md (100%) rename {.claude => plugins/internal/documentation}/skills/code-documentation/references/surfaces/package-readme.md (100%) rename {.claude => plugins/internal/documentation}/skills/code-documentation/references/surfaces/repository-readme.md (100%) rename {.claude => plugins/internal/documentation}/skills/documentation-discipline/SKILL.md (100%) rename {.claude => plugins/internal/documentation}/skills/documentation-discipline/references/discipline-rules.md (100%) rename plugins/store/{.claude-plugin => }/plugin.json (71%) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json deleted file mode 100644 index 284c5ce8a..000000000 --- a/.claude-plugin/marketplace.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/claude-code-marketplace.json", - "name": "store6", - "owner": { - "name": "Matt Ramotar", - "email": "matt.ramotar@uber.com" - }, - "description": "Plugins for applications using Store.", - "plugins": [ - { - "name": "store", - "source": "./plugins/store", - "description": "Skills for applications using Store (org.mobilenativefoundation.store), starting with Store 4/5 to Store 6 migration." - } - ] -} diff --git a/AGENTS.md b/AGENTS.md index 63cb73676..a3b24b80b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,8 +7,8 @@ comments, commit messages, and pull-request bodies. ## Documentation discipline The full rules are embedded in this repository as skills: -`.claude/skills/documentation-discipline/` (governs every sentence) and -`.claude/skills/code-documentation/` (governs evidence, artifact shape, mutation, and +`plugins/internal/documentation/skills/documentation-discipline/` (governs every sentence) and +`plugins/internal/documentation/skills/code-documentation/` (governs evidence, artifact shape, mutation, and verification; composes with the discipline skill). Agents with skill support invoke them by name before documentation work; agents without it read the `SKILL.md` files and their `references/` directly. The core rules below are the load-bearing summary and apply either diff --git a/plugins/internal/documentation/README.md b/plugins/internal/documentation/README.md new file mode 100644 index 000000000..99b8303e3 --- /dev/null +++ b/plugins/internal/documentation/README.md @@ -0,0 +1,8 @@ +# Documentation plugin + +Contributor-facing skills for documentation work inside this repository. App teams that use Store should load [`plugins/store`](../../store), not this package. + +## Skills + +- `documentation-discipline`: wording, evidence, and the three-pass review for every documentation surface. +- `code-documentation`: evidence, artifact shape, mutation, and verification. Compose it with `documentation-discipline`. diff --git a/plugins/internal/documentation/plugin.json b/plugins/internal/documentation/plugin.json new file mode 100644 index 000000000..cd7641d8d --- /dev/null +++ b/plugins/internal/documentation/plugin.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "documentation", + "version": "0.1.0", + "description": "Contributor-facing documentation skills for work inside this repository.", + "author": { + "name": "Matt Ramotar", + "email": "matt.ramotar@uber.com" + }, + "repository": "https://github.com/matt-ramotar/Store6", + "license": "Apache-2.0", + "keywords": [ + "documentation", + "readme", + "kdoc" + ] +} diff --git a/.claude/skills/code-documentation/SKILL.md b/plugins/internal/documentation/skills/code-documentation/SKILL.md similarity index 100% rename from .claude/skills/code-documentation/SKILL.md rename to plugins/internal/documentation/skills/code-documentation/SKILL.md diff --git a/.claude/skills/code-documentation/references/evidence-and-verification.md b/plugins/internal/documentation/skills/code-documentation/references/evidence-and-verification.md similarity index 100% rename from .claude/skills/code-documentation/references/evidence-and-verification.md rename to plugins/internal/documentation/skills/code-documentation/references/evidence-and-verification.md diff --git a/.claude/skills/code-documentation/references/languages/go.md b/plugins/internal/documentation/skills/code-documentation/references/languages/go.md similarity index 100% rename from .claude/skills/code-documentation/references/languages/go.md rename to plugins/internal/documentation/skills/code-documentation/references/languages/go.md diff --git a/.claude/skills/code-documentation/references/languages/java.md b/plugins/internal/documentation/skills/code-documentation/references/languages/java.md similarity index 100% rename from .claude/skills/code-documentation/references/languages/java.md rename to plugins/internal/documentation/skills/code-documentation/references/languages/java.md diff --git a/.claude/skills/code-documentation/references/languages/kotlin.md b/plugins/internal/documentation/skills/code-documentation/references/languages/kotlin.md similarity index 100% rename from .claude/skills/code-documentation/references/languages/kotlin.md rename to plugins/internal/documentation/skills/code-documentation/references/languages/kotlin.md diff --git a/.claude/skills/code-documentation/references/languages/python.md b/plugins/internal/documentation/skills/code-documentation/references/languages/python.md similarity index 100% rename from .claude/skills/code-documentation/references/languages/python.md rename to plugins/internal/documentation/skills/code-documentation/references/languages/python.md diff --git a/.claude/skills/code-documentation/references/languages/rust.md b/plugins/internal/documentation/skills/code-documentation/references/languages/rust.md similarity index 100% rename from .claude/skills/code-documentation/references/languages/rust.md rename to plugins/internal/documentation/skills/code-documentation/references/languages/rust.md diff --git a/.claude/skills/code-documentation/references/languages/typescript-javascript.md b/plugins/internal/documentation/skills/code-documentation/references/languages/typescript-javascript.md similarity index 100% rename from .claude/skills/code-documentation/references/languages/typescript-javascript.md rename to plugins/internal/documentation/skills/code-documentation/references/languages/typescript-javascript.md diff --git a/.claude/skills/code-documentation/references/surfaces/implementation-documentation.md b/plugins/internal/documentation/skills/code-documentation/references/surfaces/implementation-documentation.md similarity index 100% rename from .claude/skills/code-documentation/references/surfaces/implementation-documentation.md rename to plugins/internal/documentation/skills/code-documentation/references/surfaces/implementation-documentation.md diff --git a/.claude/skills/code-documentation/references/surfaces/inline-documentation.md b/plugins/internal/documentation/skills/code-documentation/references/surfaces/inline-documentation.md similarity index 100% rename from .claude/skills/code-documentation/references/surfaces/inline-documentation.md rename to plugins/internal/documentation/skills/code-documentation/references/surfaces/inline-documentation.md diff --git a/.claude/skills/code-documentation/references/surfaces/interface-documentation.md b/plugins/internal/documentation/skills/code-documentation/references/surfaces/interface-documentation.md similarity index 100% rename from .claude/skills/code-documentation/references/surfaces/interface-documentation.md rename to plugins/internal/documentation/skills/code-documentation/references/surfaces/interface-documentation.md diff --git a/.claude/skills/code-documentation/references/surfaces/package-readme.md b/plugins/internal/documentation/skills/code-documentation/references/surfaces/package-readme.md similarity index 100% rename from .claude/skills/code-documentation/references/surfaces/package-readme.md rename to plugins/internal/documentation/skills/code-documentation/references/surfaces/package-readme.md diff --git a/.claude/skills/code-documentation/references/surfaces/repository-readme.md b/plugins/internal/documentation/skills/code-documentation/references/surfaces/repository-readme.md similarity index 100% rename from .claude/skills/code-documentation/references/surfaces/repository-readme.md rename to plugins/internal/documentation/skills/code-documentation/references/surfaces/repository-readme.md diff --git a/.claude/skills/documentation-discipline/SKILL.md b/plugins/internal/documentation/skills/documentation-discipline/SKILL.md similarity index 100% rename from .claude/skills/documentation-discipline/SKILL.md rename to plugins/internal/documentation/skills/documentation-discipline/SKILL.md diff --git a/.claude/skills/documentation-discipline/references/discipline-rules.md b/plugins/internal/documentation/skills/documentation-discipline/references/discipline-rules.md similarity index 100% rename from .claude/skills/documentation-discipline/references/discipline-rules.md rename to plugins/internal/documentation/skills/documentation-discipline/references/discipline-rules.md diff --git a/plugins/store/README.md b/plugins/store/README.md index 5ceb49265..934b2adbf 100644 --- a/plugins/store/README.md +++ b/plugins/store/README.md @@ -1,6 +1,8 @@ # Store plugin -Skills for applications using [Store](https://github.com/MobileNativeFoundation/Store) (`org.mobilenativefoundation.store`). They are consumer-facing: an app team installs them into the codebase that uses Store. The skills in `.claude/skills/` at the repository root are the contributor-facing counterpart and apply only to work inside this repository. +Skills for applications using [Store](https://github.com/MobileNativeFoundation/Store) (`org.mobilenativefoundation.store`). They are consumer-facing: an app team loads them into the codebase that uses Store. The contributor-facing counterpart is [`plugins/internal/documentation`](../internal/documentation) and applies only to work inside this repository. + +This package is an Agent Plugin directory: `plugin.json` at the plugin root and skills as immediate children of `skills/`. ## Skills @@ -9,4 +11,4 @@ Skills for applications using [Store](https://github.com/MobileNativeFoundation/ ## Maintenance -Skill content derives from this repository's source and documentation. Every API spelling is verified against a named commit, recorded in the "Last verified" line at the bottom of each SKILL.md. When `main` moves in ways that touch a documented surface, re-verify, update the stamp, and bump the version in `.claude-plugin/plugin.json`. +Skill content derives from this repository's source and documentation. Every API spelling is verified against a named commit, recorded in the "Last verified" line at the bottom of each SKILL.md. When `main` moves in ways that touch a documented surface, re-verify, update the stamp, and bump the version in `plugin.json`. diff --git a/plugins/store/.claude-plugin/plugin.json b/plugins/store/plugin.json similarity index 71% rename from plugins/store/.claude-plugin/plugin.json rename to plugins/store/plugin.json index eb35377e2..b9c1a7b30 100644 --- a/plugins/store/.claude-plugin/plugin.json +++ b/plugins/store/plugin.json @@ -1,13 +1,14 @@ { - "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "store", - "displayName": "Store", + "version": "0.3.0", "description": "Skills for applications using Store (org.mobilenativefoundation.store): building a Store 6 data layer and migrating from Store 4/5.", - "version": "0.2.0", "author": { "name": "Matt Ramotar", "email": "matt.ramotar@uber.com" }, + "repository": "https://github.com/matt-ramotar/Store6", + "license": "Apache-2.0", "keywords": [ "store", "store6",