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..03567e27a --- /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 +compose/README.md +room/README.md +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/benchmarks.yml b/.github/workflows/benchmarks.yml new file mode 100644 index 000000000..7b97f7200 --- /dev/null +++ b/.github/workflows/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 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: + - 'benchmarks/**' + - '.github/workflows/benchmarks.yml' + +permissions: + contents: read + +concurrency: + group: 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 :benchmarks:smokeBenchmark --stacktrace + + - name: Summarize results + shell: bash + run: | + set -euo pipefail + reports_dir="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 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: benchmarks-smoke-${{ github.run_id }} + path: benchmarks/build/reports/benchmarks/ + if-no-files-found: error diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9cd3b5a91..03a5aa275 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 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 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 mutations/src/commonTest mutations/src/jvmTest -name '*Test.kt' | wc -l | tr -d ' ') - 1 )) + executed=$(find 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-full-jvm.yml b/.github/workflows/store6-full-jvm.yml new file mode 100644 index 000000000..30747108d --- /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 :mutations:jvmTest -Pstore6.fullJvmSuite --stacktrace + + - name: Census — no suite may be lost + shell: bash + run: | + set -euo pipefail + expected=$(find mutations/src/commonTest mutations/src/jvmTest -name '*Test.kt' | wc -l | tr -d ' ') + executed=$(find 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: | + mutations/build/test-results/**/*.xml + 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..dad263baf --- /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, compose/README.md, or 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 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 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 :core:build :testing:build :sqldelight:build + -Pkotlin.native.enableKlibsCrossCompilation=true + -Pkotlin.apple.xcodeCompatibility.nowarn=true + --stacktrace + + - name: Run Store6 quickstart + run: ./gradlew :quickstart:run --stacktrace + + - name: Run Store6 sqldelight sample + run: ./gradlew :sqldelight-sample:run --stacktrace + + - name: Build Store6 extension probe (seam-only consumer) + run: > + ./gradlew :extension-probe:build + -Pkotlin.native.enableKlibsCrossCompilation=true + -Pkotlin.apple.xcodeCompatibility.nowarn=true + --stacktrace + + - name: Build Store6 compose and demo + run: > + ./gradlew :compose:build :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=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 compose-demo/build + ./gradlew :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 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 :room:build + -Pkotlin.native.enableKlibsCrossCompilation=true + -Pkotlin.apple.xcodeCompatibility.nowarn=true + --stacktrace + + - name: Run Store6 Room sample + run: ./gradlew :room-sample:run --stacktrace + + - name: Build Store6 benchmarks + run: ./gradlew :benchmarks:build --stacktrace + + - name: Build Store6 devtools modules and demo + run: > + ./gradlew :devtools:build :devtools-inspector:build :devtools-demo:build + -Pkotlin.native.enableKlibsCrossCompilation=true + -Pkotlin.apple.xcodeCompatibility.nowarn=true + --stacktrace + + - name: Build Store6 mutations + run: > + ./gradlew :mutations:build + -Pkotlin.native.enableKlibsCrossCompilation=true + -Pkotlin.apple.xcodeCompatibility.nowarn=true + --stacktrace + + - name: Run Store6 mutations quickstart + run: ./gradlew :mutations-quickstart:run --stacktrace + + - name: Census — default jvmTest must execute exactly the non-Lincheck suites + shell: bash + run: | + set -euo pipefail + expected=$(( $(find mutations/src/commonTest mutations/src/jvmTest -name '*Test.kt' | wc -l | tr -d ' ') - 1 )) + executed=$(find 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 :mutations-testing:build :mutations-sqldelight:build + -Pkotlin.native.enableKlibsCrossCompilation=true + -Pkotlin.apple.xcodeCompatibility.nowarn=true + --stacktrace + + - name: Build Store6 paging-androidx + run: > + ./gradlew :paging-androidx:build + -Pkotlin.native.enableKlibsCrossCompilation=true + -Pkotlin.apple.xcodeCompatibility.nowarn=true + --stacktrace + + - name: Run Store6 paging sample + run: ./gradlew :paging-androidx-sample:run --stacktrace + + - name: Build Store6 graphql + run: > + ./gradlew :graphql:build + -Pkotlin.native.enableKlibsCrossCompilation=true + -Pkotlin.apple.xcodeCompatibility.nowarn=true + --stacktrace + + - name: Run Store6 graphql sample + run: ./gradlew :graphql-sample:run --stacktrace + + - name: Build Store6 realtime + run: > + ./gradlew :realtime:build + -Pkotlin.native.enableKlibsCrossCompilation=true + -Pkotlin.apple.xcodeCompatibility.nowarn=true + --stacktrace + + - name: Run Store6 realtime sample + run: ./gradlew :realtime-sample:run --stacktrace + + - name: Reject core-internal access from extension modules + shell: bash + run: | + set -euo pipefail + status=0 + for module in extension-probe testing sqldelight compose compose-demo room benchmarks devtools devtools-inspector devtools-demo mutations mutations-quickstart mutations-testing mutations-sqldelight paging-androidx paging-androidx/sample graphql graphql/sample realtime 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 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="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=(core/src/*Main testing/src/*Main sqldelight/src/*Main compose/src/*Main room/src/*Main devtools/src/*Main devtools-inspector/src/*Main mutations/src/*Main mutations-testing/src/*Main mutations-sqldelight/src/*Main paging-androidx/src/*Main graphql/src/*Main 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="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 :core:jsNodeTest :testing:jsNodeTest :mutations:jsNodeTest :mutations-testing:jsNodeTest :paging-androidx:jsNodeTest :graphql:jsNodeTest :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: | + */build/test-results/**/*.xml + */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 \ + :core:iosSimulatorArm64Test \ + :core:macosArm64Test \ + :extension-probe:iosSimulatorArm64Test \ + :extension-probe:macosArm64Test \ + :testing:iosSimulatorArm64Test \ + :testing:macosArm64Test \ + :sqldelight:iosSimulatorArm64Test \ + :sqldelight:macosArm64Test \ + :compose:iosSimulatorArm64Test \ + :compose:macosArm64Test \ + :room:iosSimulatorArm64Test \ + :room:macosArm64Test \ + :paging-androidx:iosSimulatorArm64Test \ + :paging-androidx:macosArm64Test \ + :graphql:iosSimulatorArm64Test \ + :graphql:macosArm64Test \ + :realtime:iosSimulatorArm64Test \ + :realtime:macosArm64Test \ + :devtools:iosSimulatorArm64Test \ + :devtools:macosArm64Test \ + :devtools-inspector:iosSimulatorArm64Test \ + :devtools-inspector:macosArm64Test \ + :mutations:iosSimulatorArm64Test \ + :mutations:macosArm64Test \ + :mutations-testing:iosSimulatorArm64Test \ + :mutations-testing:macosArm64Test \ + :mutations-sqldelight:iosSimulatorArm64Test \ + :mutations-sqldelight:macosArm64Test \ + "-Pstore6.iosSimulatorDevice=${STORE6_IOS_SIMULATOR_DEVICE}" \ + --stacktrace + + - name: Link Store6 devtools demo iOS framework + run: ./gradlew :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: | + */build/test-results/**/*.xml + */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: | + swift-dumps/**/build/swift-dump/** + swift-dumps/**/build/bin/iosArm64/debugFramework/**/*.h + swift-dumps/**/build/skie/** + core/api/swift/** + 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 :core:publishToMavenLocal :testing:publishToMavenLocal :sqldelight:publishToMavenLocal :compose:publishToMavenLocal :room:publishToMavenLocal :devtools:publishToMavenLocal :devtools-inspector:publishToMavenLocal :mutations:publishToMavenLocal :mutations-testing:publishToMavenLocal :mutations-sqldelight:publishToMavenLocal :paging-androidx:publishToMavenLocal :graphql:publishToMavenLocal :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=(core testing sqldelight compose room devtools devtools-inspector mutations mutations-testing mutations-sqldelight paging-androidx graphql 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 + paging-androidx:-iosx64) + # paging-androidx ships the paging-common-3.5.1 target subset; androidx.paging + # publishes no Intel artifacts since 3.4.0-rc01. + continue + ;; + room:-js|room:-wasm-js|room:-mingwx64|room:-iosx64) + # room ships Room 3's target subset; room3 publishes no iosX64 and no + # web/mingw klibs for this module's scope. + continue + ;; + devtools-inspector:-watchosarm64|devtools-inspector:-tvosarm64|devtools-inspector:-linuxx64|devtools-inspector:-mingwx64) + # 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 :core:macosArm64Test \ + --tests '*StoreEvictionStressTest' \ + --tests '*StoreInvalidationStressTest' \ + --tests '*StoreCloseLifecycleTest' \ + --tests '*StoreBackpressureConformanceTest' \ + --stacktrace + + result_dir="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: | + */build/test-results/**/*.xml + */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..186aa10de --- /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: +`plugins/internal/documentation/skills/documentation-discipline/` (governs every sentence) and +`plugins/internal/documentation/skills/code-documentation/` (governs evidence, artifact shape, mutation, and +verification). It 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/Images/friendly_robot.png b/Images/friendly_robot.png deleted file mode 100644 index 424a3f78c..000000000 Binary files a/Images/friendly_robot.png and /dev/null differ diff --git a/Images/friendly_robot_icon.png b/Images/friendly_robot_icon.png deleted file mode 100755 index 558096a81..000000000 Binary files a/Images/friendly_robot_icon.png and /dev/null differ diff --git a/Images/store-1.jpg b/Images/store-1.jpg deleted file mode 100644 index e64bac66b..000000000 Binary files a/Images/store-1.jpg and /dev/null differ diff --git a/Images/store-2.jpg b/Images/store-2.jpg deleted file mode 100644 index 9f97598f6..000000000 Binary files a/Images/store-2.jpg and /dev/null differ diff --git a/Images/store-3.jpg b/Images/store-3.jpg deleted file mode 100644 index 99a991293..000000000 Binary files a/Images/store-3.jpg and /dev/null differ diff --git a/Images/store-4.jpg b/Images/store-4.jpg deleted file mode 100644 index 20bd929d3..000000000 Binary files a/Images/store-4.jpg and /dev/null differ diff --git a/Images/store-5.jpg b/Images/store-5.jpg deleted file mode 100644 index 873aecab2..000000000 Binary files a/Images/store-5.jpg and /dev/null differ diff --git a/README.md b/README.md index f67ae8b45..65417fe04 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 as `core`, `testing`, `mutations`, and the other Store 6 +artifacts in the `org.mobilenativefoundation.store` group, 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.** `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..33e0372da --- /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:** + +| | | +|---|---| +| `core`, `testing` | The engine and its conformance kit. | +| `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 + [`core/src/commonTest`](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..598b74e4e --- /dev/null +++ b/STABILITY.md @@ -0,0 +1,210 @@ +# Store 6 stability policy + +## 1. What this document is + +What each Store 6 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 Store 6 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 `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 | +|---|---|---| +| `core` | Stable-track. The API is **not frozen** until the beta01 freeze candidate. | alpha01 | +| `testing` | Experimental (`@ExperimentalStoreApi`) — every public declaration in the artifact carries the marker today. | alpha01 | +| `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 | +| `room` | Experimental adapter, same graduation. | alpha01, may slip one alpha | +| `compose` | Experimental adapter, same graduation. | alpha01, may slip one alpha | +| `mutations` | **Experimental, separate artifact — every public symbol is `@ExperimentalStoreApi`.** See [§8](#mutations). | alpha01 | +| `bom` | Version alignment only; no API surface of its own. | alpha01 | +| `devtools` | Experimental (`@ExperimentalStoreApi`). | alpha02 (target) | +| `devtools-inspector` | Experimental (`@ExperimentalStoreApi`). | alpha02 (target) | + +Inside `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: `store5-interop`, tracking to 6.0.0 and not in the alpha01 line, and +`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 + +Store 5 and Store 6 artifacts live **side by side for the whole 6.x major** in +`org.mobilenativefoundation.store`. You can depend on both in one build and migrate a screen at a +time. There is no flag day. + +`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 Store 6 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 `core/api/jvm/core.api` and + `core/api/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 (`core/api/swift/objc`, `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: + [`core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/`](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 + + + +`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 +`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/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 000000000..3d1b5fcca --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,116 @@ +# benchmarks + +`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 :benchmarks:benchmark +./gradlew :benchmarks:smokeBenchmark +./gradlew :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 +`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="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 `: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/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/benchmarks/build.gradle.kts b/benchmarks/build.gradle.kts new file mode 100644 index 000000000..586258cf0 --- /dev/null +++ b/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.core) + // FakeSourceOfTruth: the shared, contract-kit-passing SoT on BOTH sides of every ratio. + implementation(projects.testing) + 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/benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/BenchKey.kt b/benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/BenchKey.kt new file mode 100644 index 000000000..6a24e0802 --- /dev/null +++ b/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/benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/ColdStartBenchmark.kt b/benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/ColdStartBenchmark.kt new file mode 100644 index 000000000..7e1bd60d2 --- /dev/null +++ b/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/benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/GetPathBenchmark.kt b/benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/GetPathBenchmark.kt new file mode 100644 index 000000000..cf1bc688a --- /dev/null +++ b/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/benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/NoopTelemetry.kt b/benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/NoopTelemetry.kt new file mode 100644 index 000000000..86f9fe8da --- /dev/null +++ b/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/benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/StreamEmissionBenchmark.kt b/benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/StreamEmissionBenchmark.kt new file mode 100644 index 000000000..8bd6055bd --- /dev/null +++ b/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/benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/SubscriptionChurnBenchmark.kt b/benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/SubscriptionChurnBenchmark.kt new file mode 100644 index 000000000..494272c9b --- /dev/null +++ b/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/benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/TelemetryOverheadBenchmark.kt b/benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/TelemetryOverheadBenchmark.kt new file mode 100644 index 000000000..1625a81c4 --- /dev/null +++ b/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/benchmarks/src/test/kotlin/org/mobilenativefoundation/store6/benchmarks/HarnessSmokeTest.kt b/benchmarks/src/test/kotlin/org/mobilenativefoundation/store6/benchmarks/HarnessSmokeTest.kt new file mode 100644 index 000000000..04334c014 --- /dev/null +++ b/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 `: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/benchmarks/src/test/kotlin/org/mobilenativefoundation/store6/benchmarks/TelemetryAllocationProbe.kt b/benchmarks/src/test/kotlin/org/mobilenativefoundation/store6/benchmarks/TelemetryAllocationProbe.kt new file mode 100644 index 000000000..dcf29b8e8 --- /dev/null +++ b/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/build.gradle.kts b/build.gradle.kts index b44c87ae5..79ba5e76c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,39 +1,107 @@ -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) + } + } + } + } + + // Store 6 modules use their own formatting conventions. + 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( + ":swift-dumps-objc:refreshSwiftDump", + ":swift-dumps-skie:refreshSwiftDump", + ":swift-dumps-mutations-objc:refreshSwiftDump", + ":swift-dumps-mutations-skie:refreshSwiftDump", + ) +} + +tasks.register("checkSwiftDumps") { + dependsOn( + ":swift-dumps-objc:checkSwiftDump", + ":swift-dumps-skie:checkSwiftDump", + ":swift-dumps-mutations-objc:checkSwiftDump", + ":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/compose-demo/build.gradle.kts b/compose-demo/build.gradle.kts new file mode 100644 index 000000000..b42706b06 --- /dev/null +++ b/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("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.compose) + implementation(projects.testing) + 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/compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/DemoScreen.kt b/compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/DemoScreen.kt new file mode 100644 index 000000000..cf1320001 --- /dev/null +++ b/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/compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/DemoStore.kt b/compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/DemoStore.kt new file mode 100644 index 000000000..3fa00d5cb --- /dev/null +++ b/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 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/compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/DemoUiState.kt b/compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/DemoUiState.kt new file mode 100644 index 000000000..718fcce79 --- /dev/null +++ b/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/compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/Main.kt b/compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/Main.kt new file mode 100644 index 000000000..a38176350 --- /dev/null +++ b/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 = "compose demo") { + DemoScreen(users, controls) + } +} diff --git a/compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/StabilityProbe.kt b/compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/StabilityProbe.kt new file mode 100644 index 000000000..5dbe38e37 --- /dev/null +++ b/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 (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/compose-demo/src/test/kotlin/org/mobilenativefoundation/store6/composedemo/DemoFetcherTest.kt b/compose-demo/src/test/kotlin/org/mobilenativefoundation/store6/composedemo/DemoFetcherTest.kt new file mode 100644 index 000000000..2d5bfb322 --- /dev/null +++ b/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/compose-demo/src/test/kotlin/org/mobilenativefoundation/store6/composedemo/DemoUiStateTest.kt b/compose-demo/src/test/kotlin/org/mobilenativefoundation/store6/composedemo/DemoUiStateTest.kt new file mode 100644 index 000000000..5afdae184 --- /dev/null +++ b/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/compose/README.md b/compose/README.md new file mode 100644 index 000000000..20ee728c5 --- /dev/null +++ b/compose/README.md @@ -0,0 +1,61 @@ +# 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 :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/compose/api/android/compose.api b/compose/api/android/compose.api new file mode 100644 index 000000000..304aa0b10 --- /dev/null +++ b/compose/api/android/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/compose/api/compose.klib.api b/compose/api/compose.klib.api new file mode 100644 index 000000000..54a3b70c4 --- /dev/null +++ b/compose/api/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/compose/api/jvm/compose.api b/compose/api/jvm/compose.api new file mode 100644 index 000000000..304aa0b10 --- /dev/null +++ b/compose/api/jvm/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/compose/build.gradle.kts b/compose/build.gradle.kts new file mode 100644 index 000000000..f2325ce58 --- /dev/null +++ b/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.core) + // 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.testing) + 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/compose/gradle.properties b/compose/gradle.properties new file mode 100644 index 000000000..33ce6966d --- /dev/null +++ b/compose/gradle.properties @@ -0,0 +1,3 @@ +VERSION_NAME=6.0.0-SNAPSHOT +POM_NAME=compose +POM_ARTIFACT_ID=compose diff --git a/rx2/src/main/AndroidManifest.xml b/compose/src/androidMain/AndroidManifest.xml similarity index 100% rename from rx2/src/main/AndroidManifest.xml rename to compose/src/androidMain/AndroidManifest.xml diff --git a/compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/CollectAsState.kt b/compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/CollectAsState.kt new file mode 100644 index 000000000..de42634ae --- /dev/null +++ b/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/compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/CollectAsStateWithLifecycle.kt b/compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/CollectAsStateWithLifecycle.kt new file mode 100644 index 000000000..2e78f767d --- /dev/null +++ b/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/compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/StoreResultEquivalence.kt b/compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/StoreResultEquivalence.kt new file mode 100644 index 000000000..f51cd893b --- /dev/null +++ b/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 compose convenience for stateIn/ViewModel + * consumers; `conflateLatestData` governs 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/compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/ClosedStoreBehaviorTest.kt b/compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/ClosedStoreBehaviorTest.kt new file mode 100644 index 000000000..4d318a007 --- /dev/null +++ b/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/compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/CollectAsStateTest.kt b/compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/CollectAsStateTest.kt new file mode 100644 index 000000000..923736a29 --- /dev/null +++ b/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/compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/CollectAsStateWithLifecycleTest.kt b/compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/CollectAsStateWithLifecycleTest.kt new file mode 100644 index 000000000..af87b67bc --- /dev/null +++ b/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/compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/ComposeTestHarness.kt b/compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/ComposeTestHarness.kt new file mode 100644 index 000000000..cff723e70 --- /dev/null +++ b/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/compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/SkipEqualDataTest.kt b/compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/SkipEqualDataTest.kt new file mode 100644 index 000000000..da05a15e5 --- /dev/null +++ b/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/compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/StoreResultMutationPolicyTest.kt b/compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/StoreResultMutationPolicyTest.kt new file mode 100644 index 000000000..bcd7e5b7f --- /dev/null +++ b/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/compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/docs/PendingWriteUiDocsSnippet.kt b/compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/docs/PendingWriteUiDocsSnippet.kt new file mode 100644 index 000000000..59df5d26f --- /dev/null +++ b/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/compose/stability/store6-stability.conf b/compose/stability/store6-stability.conf new file mode 100644 index 000000000..bc811029f --- /dev/null +++ b/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/core/api/android/core.api b/core/api/android/core.api new file mode 100644 index 000000000..fa9ffa6cc --- /dev/null +++ b/core/api/android/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/core/api/core.klib.api b/core/api/core.klib.api new file mode 100644 index 000000000..3b09362fa --- /dev/null +++ b/core/api/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/core/api/jvm/core.api b/core/api/jvm/core.api index 7a452a0a2..fa9ffa6cc 100644 --- a/core/api/jvm/core.api +++ b/core/api/jvm/core.api @@ -1,69 +1,344 @@ -public abstract interface annotation class org/mobilenativefoundation/store/core5/ExperimentalStoreApi : java/lang/annotation/Annotation { +public abstract interface annotation class org/mobilenativefoundation/store6/core/DelicateStoreApi : 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 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/store/core5/InsertionStrategy; - public static fun values ()[Lorg/mobilenativefoundation/store/core5/InsertionStrategy; + 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/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/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 abstract interface class org/mobilenativefoundation/store/core5/StoreData { +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 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 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 abstract interface class org/mobilenativefoundation/store/core5/StoreData$Single : org/mobilenativefoundation/store/core5/StoreData { - public abstract fun getId ()Ljava/lang/Object; +public final class org/mobilenativefoundation/store6/core/StoreBuilderKt { + public static final fun store (Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store6/core/Store; } -public abstract interface class org/mobilenativefoundation/store/core5/StoreKey { +public abstract class org/mobilenativefoundation/store6/core/StoreError { } -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 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 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 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 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 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 abstract interface class org/mobilenativefoundation/store/core5/StoreKey$Filter { - public abstract fun invoke (Ljava/util/List;)Ljava/util/List; +public final class org/mobilenativefoundation/store6/core/StoreError$FreshnessUnsatisfiable : org/mobilenativefoundation/store6/core/StoreError { + public final fun getMessage ()Ljava/lang/String; } -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/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/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; +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/core/api/swift/objc/Store6Core.h b/core/api/swift/objc/Store6Core.h new file mode 100644 index 000000000..39dfa6d32 --- /dev/null +++ b/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/core/api/swift/skie/Store6CoreSkie.h b/core/api/swift/skie/Store6CoreSkie.h new file mode 100644 index 000000000..a6fc0e587 --- /dev/null +++ b/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/core/api/swift/skie/Store6CoreSkie.swift b/core/api/swift/skie/Store6CoreSkie.swift new file mode 100644 index 000000000..6c8bc264b --- /dev/null +++ b/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/core/build.gradle.kts b/core/build.gradle.kts index 171a92669..d8942dd2b 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -1,14 +1,37 @@ plugins { - id("org.mobilenativefoundation.store.multiplatform") + 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 { - commonMain { + val commonMain by getting { dependencies { - implementation(libs.kotlin.stdlib) + api(libs.kotlinx.coroutines.core) + } + } + + val commonTest by getting { + dependencies { + implementation(projects.testing) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.turbine) } } } } + +android { + namespace = "org.mobilenativefoundation.store6.core" +} diff --git a/core/dokka/Module.md b/core/dokka/Module.md new file mode 100644 index 000000000..df4e601b9 --- /dev/null +++ b/core/dokka/Module.md @@ -0,0 +1,17 @@ +# Module 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 + +`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 +[mutations reference](https://store.mobilenativefoundation.org/reference/mutations/index.html). diff --git a/core/gradle.properties b/core/gradle.properties index 1fe16b330..b1fc4a933 100644 --- a/core/gradle.properties +++ b/core/gradle.properties @@ -1,3 +1,3 @@ -POM_NAME=org.mobilenativefoundation.store -POM_ARTIFACT_ID=core5 -POM_PACKAGING=jar \ No newline at end of file +VERSION_NAME=6.0.0-SNAPSHOT +POM_NAME=core +POM_ARTIFACT_ID=core diff --git a/core/config/ktlint/baseline.xml b/core/src/androidMain/AndroidManifest.xml similarity index 51% rename from core/config/ktlint/baseline.xml rename to core/src/androidMain/AndroidManifest.xml index 981420778..8072ee00d 100644 --- a/core/config/ktlint/baseline.xml +++ b/core/src/androidMain/AndroidManifest.xml @@ -1,3 +1,2 @@ - - + diff --git a/core/src/androidMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.android.kt b/core/src/androidMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.android.kt new file mode 100644 index 000000000..dbf43f9c0 --- /dev/null +++ b/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/core/src/appleMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.apple.kt b/core/src/appleMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.apple.kt new file mode 100644 index 000000000..d6ab14697 --- /dev/null +++ b/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/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Annotations.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Annotations.kt new file mode 100644 index 000000000..827aa29bb --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Freshness.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Freshness.kt new file mode 100644 index 000000000..e1c02344d --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Origin.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Origin.kt new file mode 100644 index 000000000..d1fe4efd4 --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Store.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Store.kt new file mode 100644 index 000000000..2a9bf2b08 --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreBuilder.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreBuilder.kt new file mode 100644 index 000000000..80b922b61 --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreError.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreError.kt new file mode 100644 index 000000000..db317bbe7 --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreException.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreException.kt new file mode 100644 index 000000000..f47ab940a --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreKey.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreKey.kt new file mode 100644 index 000000000..77d8e2c6f --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreMeta.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreMeta.kt new file mode 100644 index 000000000..41d9a66c3 --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreResult.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreResult.kt new file mode 100644 index 000000000..1a257b577 --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/Bookkeeper.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/Bookkeeper.kt new file mode 100644 index 000000000..7052bb20b --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/EngineResidency.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/EngineResidency.kt new file mode 100644 index 000000000..b20615b62 --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/FetchSlot.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/FetchSlot.kt new file mode 100644 index 000000000..ccbaa18b5 --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/FetcherAdapters.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/FetcherAdapters.kt new file mode 100644 index 000000000..3a8d33ba4 --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/FreshnessValidator.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/FreshnessValidator.kt new file mode 100644 index 000000000..2ba32b6ba --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/InMemorySourceOfTruth.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/InMemorySourceOfTruth.kt new file mode 100644 index 000000000..9f3402b49 --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyEngine.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyEngine.kt new file mode 100644 index 000000000..7c894c6c7 --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyId.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyId.kt new file mode 100644 index 000000000..38cb4b310 --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyRegistry.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyRegistry.kt new file mode 100644 index 000000000..1502a1d1c --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyState.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyState.kt new file mode 100644 index 000000000..cdf7dc9f5 --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/MaintenanceCoordinator.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/MaintenanceCoordinator.kt new file mode 100644 index 000000000..8055c4262 --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/Projection.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/Projection.kt new file mode 100644 index 000000000..393efccb9 --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/ReaderRecord.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/ReaderRecord.kt new file mode 100644 index 000000000..9f7fee1e2 --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/RealStore.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/RealStore.kt new file mode 100644 index 000000000..69ed589eb --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/RealStoreRuntime.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/RealStoreRuntime.kt new file mode 100644 index 000000000..ab1388306 --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/StoreLifecycle.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/StoreLifecycle.kt new file mode 100644 index 000000000..564da0f22 --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/StoreResultFlows.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/StoreResultFlows.kt new file mode 100644 index 000000000..87ed6f500 --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/Transitions.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/Transitions.kt new file mode 100644 index 000000000..f3c5d92ce --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/ValueEnvelope.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/ValueEnvelope.kt new file mode 100644 index 000000000..e29765bae --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.kt new file mode 100644 index 000000000..3d55af8ef --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Bookkeeper.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Bookkeeper.kt new file mode 100644 index 000000000..f24cc4c41 --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Fetcher.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Fetcher.kt new file mode 100644 index 000000000..c04e9191b --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FetcherResult.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FetcherResult.kt new file mode 100644 index 000000000..6a3fbd915 --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FreshnessValidator.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FreshnessValidator.kt new file mode 100644 index 000000000..f3672aaf3 --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/KeyEvents.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/KeyEvents.kt new file mode 100644 index 000000000..fb901843f --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Overlay.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Overlay.kt new file mode 100644 index 000000000..a1cdc2f12 --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/SourceOfTruth.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/SourceOfTruth.kt new file mode 100644 index 000000000..0c34e9bd5 --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreResults.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreResults.kt new file mode 100644 index 000000000..2709e1508 --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreRuntime.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreRuntime.kt new file mode 100644 index 000000000..250d5bc0a --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreTelemetry.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreTelemetry.kt new file mode 100644 index 000000000..c1deee2f9 --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreWriteHandle.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreWriteHandle.kt new file mode 100644 index 000000000..ba0955286 --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/TransactionalSourceOfTruth.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/TransactionalSourceOfTruth.kt new file mode 100644 index 000000000..369d7e14e --- /dev/null +++ b/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/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/WallClock.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/WallClock.kt new file mode 100644 index 000000000..1ce8dc915 --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/EmissionSequenceConformanceTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/EmissionSequenceConformanceTest.kt new file mode 100644 index 000000000..6d27bdb1e --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/FetcherContractTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/FetcherContractTest.kt new file mode 100644 index 000000000..abfa34dfe --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/FreshnessPolicyConformanceTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/FreshnessPolicyConformanceTest.kt new file mode 100644 index 000000000..68a67dbfc --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/KeyEngineSourceOfTruthRaceTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/KeyEngineSourceOfTruthRaceTest.kt new file mode 100644 index 000000000..22de08072 --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/MaintenanceTestFakes.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/MaintenanceTestFakes.kt new file mode 100644 index 000000000..a854bf363 --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/NamespacedTestKey.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/NamespacedTestKey.kt new file mode 100644 index 000000000..e3c9651cb --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/OverlayConformanceTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/OverlayConformanceTest.kt new file mode 100644 index 000000000..4fc51b95e --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/PublicSurfaceTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/PublicSurfaceTest.kt new file mode 100644 index 000000000..000eefafb --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/ReaderGenerationRecoveryTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/ReaderGenerationRecoveryTest.kt new file mode 100644 index 000000000..40d2749af --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/ReaderHopSourceOfTruth.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/ReaderHopSourceOfTruth.kt new file mode 100644 index 000000000..61b263c9e --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SchedulerPerturbationRuns.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SchedulerPerturbationRuns.kt new file mode 100644 index 000000000..e89c9bc97 --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SeamFetcherTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SeamFetcherTest.kt new file mode 100644 index 000000000..6dbc85029 --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SingleFlightConformanceTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SingleFlightConformanceTest.kt new file mode 100644 index 000000000..1775058ed --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthAdditionalRaceTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthAdditionalRaceTest.kt new file mode 100644 index 000000000..d420db16b --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthBindingConformanceTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthBindingConformanceTest.kt new file mode 100644 index 000000000..2a7cd0759 --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthCancellationConformanceTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthCancellationConformanceTest.kt new file mode 100644 index 000000000..2ec0e084f --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthConformanceTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthConformanceTest.kt new file mode 100644 index 000000000..b7a815779 --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthFailureConformanceTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthFailureConformanceTest.kt new file mode 100644 index 000000000..362727a98 --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthHydrationRaceTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthHydrationRaceTest.kt new file mode 100644 index 000000000..9814883d7 --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthSubstitutionTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthSubstitutionTest.kt new file mode 100644 index 000000000..13935ab8f --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreBackpressureConformanceTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreBackpressureConformanceTest.kt new file mode 100644 index 000000000..295e639e0 --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreBuilderPersistenceTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreBuilderPersistenceTest.kt new file mode 100644 index 000000000..d2806e801 --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreCloseLifecycleTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreCloseLifecycleTest.kt new file mode 100644 index 000000000..dddd6d41f --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConfigSeamsTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConfigSeamsTest.kt new file mode 100644 index 000000000..f7ed6c8a5 --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConformanceSubstitutionRuns.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConformanceSubstitutionRuns.kt new file mode 100644 index 000000000..47d82ff52 --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConformanceTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConformanceTest.kt new file mode 100644 index 000000000..7cc8a555d --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreDefaultsPinTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreDefaultsPinTest.kt new file mode 100644 index 000000000..575a78f02 --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreDurableMaintenanceConformanceTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreDurableMaintenanceConformanceTest.kt new file mode 100644 index 000000000..39fed9a51 --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreDurableMaintenanceFailureTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreDurableMaintenanceFailureTest.kt new file mode 100644 index 000000000..1ce0be684 --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreEvictionConformanceTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreEvictionConformanceTest.kt new file mode 100644 index 000000000..df68a35e2 --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreEvictionStressTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreEvictionStressTest.kt new file mode 100644 index 000000000..f7940976b --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreInvalidationConformanceTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreInvalidationConformanceTest.kt new file mode 100644 index 000000000..b949721b6 --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreInvalidationStressTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreInvalidationStressTest.kt new file mode 100644 index 000000000..7f09d294e --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreResultsTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreResultsTest.kt new file mode 100644 index 000000000..35a248054 --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreRevalidationConformanceTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreRevalidationConformanceTest.kt new file mode 100644 index 000000000..a36166d6a --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreRuntimeTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreRuntimeTest.kt new file mode 100644 index 000000000..c33d3dabb --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreScopedMaintenanceRaceTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreScopedMaintenanceRaceTest.kt new file mode 100644 index 000000000..12e20642e --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreTelemetryTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreTelemetryTest.kt new file mode 100644 index 000000000..6b6b9cc17 --- /dev/null +++ b/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 benchmarks. + } +} diff --git a/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreZeroConfigEquivalenceTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreZeroConfigEquivalenceTest.kt new file mode 100644 index 000000000..5a010701f --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/TestBarriers.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/TestBarriers.kt new file mode 100644 index 000000000..8b11b6b1f --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/TestClocks.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/TestClocks.kt new file mode 100644 index 000000000..e13a39cbf --- /dev/null +++ b/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 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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/TestKey.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/TestKey.kt new file mode 100644 index 000000000..61cc51556 --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/docs/GuideSnippetCompilation.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/docs/GuideSnippetCompilation.kt new file mode 100644 index 000000000..a7b4b1629 --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/BookkeeperOrderingConformanceTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/BookkeeperOrderingConformanceTest.kt new file mode 100644 index 000000000..2c85d59e2 --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/ConflateLatestDataTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/ConflateLatestDataTest.kt new file mode 100644 index 000000000..836090b4b --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/FreshnessValidatorTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/FreshnessValidatorTest.kt new file mode 100644 index 000000000..c479a21bd --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/InMemoryBookkeeperKitTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/InMemoryBookkeeperKitTest.kt new file mode 100644 index 000000000..d92f801c7 --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/InMemoryBookkeeperTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/InMemoryBookkeeperTest.kt new file mode 100644 index 000000000..c79c74ac6 --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/InMemorySourceOfTruthKitTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/InMemorySourceOfTruthKitTest.kt new file mode 100644 index 000000000..53cf39b2e --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/InMemorySourceOfTruthTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/InMemorySourceOfTruthTest.kt new file mode 100644 index 000000000..564b33bec --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/KeyEnginePlanningTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/KeyEnginePlanningTest.kt new file mode 100644 index 000000000..dd7ab1c6d --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/KeyRegistryTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/KeyRegistryTest.kt new file mode 100644 index 000000000..a2761f7b5 --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/MaintenanceCoordinatorTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/MaintenanceCoordinatorTest.kt new file mode 100644 index 000000000..4ce892009 --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/OverlayProjectionProtocolTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/OverlayProjectionProtocolTest.kt new file mode 100644 index 000000000..8ae0fefc0 --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/ProjectionAuthorizationHandoffTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/ProjectionAuthorizationHandoffTest.kt new file mode 100644 index 000000000..b5cb136a1 --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/ProjectionRecomputeVisibilityProbeTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/ProjectionRecomputeVisibilityProbeTest.kt new file mode 100644 index 000000000..7b840b35b --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/ReaderRecordResolutionTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/ReaderRecordResolutionTest.kt new file mode 100644 index 000000000..17a0e37a2 --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/RotatingSlotSourceOfTruth.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/RotatingSlotSourceOfTruth.kt new file mode 100644 index 000000000..08fcf7ab9 --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/SharedFlowSourceOfTruth.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/SharedFlowSourceOfTruth.kt new file mode 100644 index 000000000..13fcdcf7c --- /dev/null +++ b/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/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/TransitionTest.kt b/core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/TransitionTest.kt new file mode 100644 index 000000000..a487d4982 --- /dev/null +++ b/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/core/src/jsMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.js.kt b/core/src/jsMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.js.kt new file mode 100644 index 000000000..686162571 --- /dev/null +++ b/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/core/src/jvmMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.jvm.kt b/core/src/jvmMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.jvm.kt new file mode 100644 index 000000000..87b67fae6 --- /dev/null +++ b/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/core/src/jvmTest/kotlin/org/mobilenativefoundation/store6/core/FetcherDispatcherTest.kt b/core/src/jvmTest/kotlin/org/mobilenativefoundation/store6/core/FetcherDispatcherTest.kt new file mode 100644 index 000000000..404289b00 --- /dev/null +++ b/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/core/src/linuxMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.linux.kt b/core/src/linuxMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.linux.kt new file mode 100644 index 000000000..71e1fbbb5 --- /dev/null +++ b/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/core/src/mingwMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.mingw.kt b/core/src/mingwMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.mingw.kt new file mode 100644 index 000000000..c9cf4e98c --- /dev/null +++ b/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/core/src/wasmJsMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.wasmJs.kt b/core/src/wasmJsMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.wasmJs.kt new file mode 100644 index 000000000..267e153cd --- /dev/null +++ b/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/devtools-demo/README.md b/devtools-demo/README.md new file mode 100644 index 000000000..98d8f9f48 --- /dev/null +++ b/devtools-demo/README.md @@ -0,0 +1,75 @@ +# 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 :devtools-demo:run +``` + +## Android + +Connect an emulator or device, choose its serial explicitly, then build, install, and launch: + +```shell +adb devices +./gradlew :devtools-demo:assembleDebug +ANDROID_SERIAL= ./gradlew :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 :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 `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 :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 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/devtools-demo/build.gradle.kts b/devtools-demo/build.gradle.kts new file mode 100644 index 000000000..ab91dc8b7 --- /dev/null +++ b/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.core) + implementation(projects.testing) + implementation(projects.compose) + implementation(projects.devtools) + implementation(projects.devtoolsInspector) + 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/devtools-demo/iosApp/.gitignore b/devtools-demo/iosApp/.gitignore new file mode 100644 index 000000000..257645df7 --- /dev/null +++ b/devtools-demo/iosApp/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +xcuserdata/ +*.xcuserstate +xcschememanagement.plist diff --git a/devtools-demo/iosApp/Info.plist b/devtools-demo/iosApp/Info.plist new file mode 100644 index 000000000..8c3120bbe --- /dev/null +++ b/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/devtools-demo/iosApp/iosApp.xcodeproj/project.pbxproj b/devtools-demo/iosApp/iosApp.xcodeproj/project.pbxproj new file mode 100644 index 000000000..790c9086e --- /dev/null +++ b/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 :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/devtools-demo/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/devtools-demo/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/devtools-demo/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/devtools-demo/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json b/devtools-demo/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/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/devtools-demo/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/devtools-demo/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..230588010 --- /dev/null +++ b/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/devtools-demo/iosApp/iosApp/Assets.xcassets/Contents.json b/devtools-demo/iosApp/iosApp/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/devtools-demo/iosApp/iosApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/devtools-demo/iosApp/iosApp/ContentView.swift b/devtools-demo/iosApp/iosApp/ContentView.swift new file mode 100644 index 000000000..577edce74 --- /dev/null +++ b/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/devtools-demo/iosApp/iosApp/iOSApp.swift b/devtools-demo/iosApp/iosApp/iOSApp.swift new file mode 100644 index 000000000..22a927fa3 --- /dev/null +++ b/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/devtools-demo/src/androidMain/AndroidManifest.xml b/devtools-demo/src/androidMain/AndroidManifest.xml new file mode 100644 index 000000000..5d8d4c0e9 --- /dev/null +++ b/devtools-demo/src/androidMain/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/devtools-demo/src/androidMain/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/MainActivity.kt b/devtools-demo/src/androidMain/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/MainActivity.kt new file mode 100644 index 000000000..8c39290c4 --- /dev/null +++ b/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/devtools-demo/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/DemoApp.kt b/devtools-demo/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/DemoApp.kt new file mode 100644 index 000000000..6ca69ec62 --- /dev/null +++ b/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 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/devtools-demo/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/DemoFetcherTest.kt b/devtools-demo/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/DemoFetcherTest.kt new file mode 100644 index 000000000..79faffcda --- /dev/null +++ b/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/devtools-demo/src/desktopMain/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/Main.kt b/devtools-demo/src/desktopMain/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/Main.kt new file mode 100644 index 000000000..42f167cc5 --- /dev/null +++ b/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/devtools-demo/src/desktopTest/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/IosAppProjectConfigurationTest.kt b/devtools-demo/src/desktopTest/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/IosAppProjectConfigurationTest.kt new file mode 100644 index 000000000..ff05e0f71 --- /dev/null +++ b/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("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/devtools-demo/src/iosMain/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/MainViewController.kt b/devtools-demo/src/iosMain/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/MainViewController.kt new file mode 100644 index 000000000..258409a30 --- /dev/null +++ b/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/devtools-inspector/README.md b/devtools-inspector/README.md new file mode 100644 index 000000000..3395b03cb --- /dev/null +++ b/devtools-inspector/README.md @@ -0,0 +1,72 @@ +# 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: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/devtools-inspector/api/android/devtools-inspector.api b/devtools-inspector/api/android/devtools-inspector.api new file mode 100644 index 000000000..35363d8bb --- /dev/null +++ b/devtools-inspector/api/android/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/devtools-inspector/api/devtools-inspector.klib.api b/devtools-inspector/api/devtools-inspector.klib.api new file mode 100644 index 000000000..946d9d9f5 --- /dev/null +++ b/devtools-inspector/api/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/devtools-inspector/api/jvm/devtools-inspector.api b/devtools-inspector/api/jvm/devtools-inspector.api new file mode 100644 index 000000000..35363d8bb --- /dev/null +++ b/devtools-inspector/api/jvm/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/devtools-inspector/build.gradle.kts b/devtools-inspector/build.gradle.kts new file mode 100644 index 000000000..eea0e2bf6 --- /dev/null +++ b/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("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.devtools) + api(compose.runtime) + api(compose.ui) + implementation(compose.foundation) + implementation(compose.material3) + } + } + val commonTest by getting { + dependencies { + implementation(projects.testing) + 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/devtools-inspector/gradle.properties b/devtools-inspector/gradle.properties new file mode 100644 index 000000000..3ae79cc16 --- /dev/null +++ b/devtools-inspector/gradle.properties @@ -0,0 +1,3 @@ +VERSION_NAME=6.0.0-SNAPSHOT +POM_NAME=devtools-inspector +POM_ARTIFACT_ID=devtools-inspector diff --git a/multicast/config/ktlint/baseline.xml b/devtools-inspector/src/androidMain/AndroidManifest.xml similarity index 51% rename from multicast/config/ktlint/baseline.xml rename to devtools-inspector/src/androidMain/AndroidManifest.xml index 981420778..8072ee00d 100644 --- a/multicast/config/ktlint/baseline.xml +++ b/devtools-inspector/src/androidMain/AndroidManifest.xml @@ -1,3 +1,2 @@ - - + diff --git a/devtools-inspector/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtools/compose/InspectorState.kt b/devtools-inspector/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtools/compose/InspectorState.kt new file mode 100644 index 000000000..3270a98ee --- /dev/null +++ b/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/devtools-inspector/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtools/compose/InspectorUiState.kt b/devtools-inspector/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtools/compose/InspectorUiState.kt new file mode 100644 index 000000000..040b023bd --- /dev/null +++ b/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/devtools-inspector/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtools/compose/StoreInspector.kt b/devtools-inspector/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtools/compose/StoreInspector.kt new file mode 100644 index 000000000..565a0639f --- /dev/null +++ b/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/devtools-inspector/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtools/compose/DeriveInspectorUiStateTest.kt b/devtools-inspector/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtools/compose/DeriveInspectorUiStateTest.kt new file mode 100644 index 000000000..80fdf7229 --- /dev/null +++ b/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/devtools-inspector/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtools/compose/InspectorStateTest.kt b/devtools-inspector/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtools/compose/InspectorStateTest.kt new file mode 100644 index 000000000..85e2ec329 --- /dev/null +++ b/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/devtools-inspector/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtools/compose/docs/GuideInspectorSnippet.kt b/devtools-inspector/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtools/compose/docs/GuideInspectorSnippet.kt new file mode 100644 index 000000000..2b94715ea --- /dev/null +++ b/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/devtools-inspector/src/jvmTest/kotlin/org/mobilenativefoundation/store6/devtools/compose/StoreInspectorSmokeTest.kt b/devtools-inspector/src/jvmTest/kotlin/org/mobilenativefoundation/store6/devtools/compose/StoreInspectorSmokeTest.kt new file mode 100644 index 000000000..eee7b6eff --- /dev/null +++ b/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/devtools/EVENTS.md b/devtools/EVENTS.md new file mode 100644 index 000000000..c05d48386 --- /dev/null +++ b/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 +